Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0374a7611e | ||
|
|
543f54dc4f | ||
|
|
3812cd740b | ||
|
|
86abb645ce | ||
|
|
3a9e393a20 | ||
|
|
058bd5ff71 | ||
|
|
a4dc26fda8 | ||
|
|
6130e63cfb | ||
|
|
b641e80d62 | ||
|
|
df5f84c87c | ||
|
|
bbf10d7eed |
@@ -55,9 +55,11 @@ flutter test --coverage
|
|||||||
- Visualisation en temps réel des zones de score
|
- Visualisation en temps réel des zones de score
|
||||||
|
|
||||||
### Placement des impacts
|
### Placement des impacts
|
||||||
- **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour
|
- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
|
||||||
l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer
|
ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
|
||||||
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting
|
- Un tap n'ouvre jamais d'édition de score : le score reste calculé
|
||||||
|
automatiquement d'après la position de l'impact
|
||||||
|
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
|
||||||
efface tous les impacts sans toucher à la calibration
|
efface tous les impacts sans toucher à la calibration
|
||||||
|
|
||||||
### Calcul des scores
|
### Calcul des scores
|
||||||
@@ -78,6 +80,19 @@ flutter test --coverage
|
|||||||
- **Distribution régionale** : répartition des tirs par quadrant
|
- **Distribution régionale** : répartition des tirs par quadrant
|
||||||
- Filtrage par période : session, semaine, mois, toutes les sessions
|
- Filtrage par période : session, semaine, mois, toutes les sessions
|
||||||
|
|
||||||
|
### Sauvegarde (export / import JSON)
|
||||||
|
- Bouton **Exporter** en bas de l'écran Statistiques : génère un fichier JSON
|
||||||
|
(sessions + cibles + impacts + calibration, armurerie + entretien, et un
|
||||||
|
instantané des statistiques calculées) puis ouvre la feuille de partage du
|
||||||
|
système (`share_plus`) pour l'envoyer où l'on veut
|
||||||
|
- Option « Inclure les photos des cibles » : photos encodées en base64 dans le
|
||||||
|
JSON (sauvegarde complète mais fichier lourd) ; sans elles le fichier reste léger
|
||||||
|
- Bouton **Importer** (`file_selector`) : aperçu du contenu avant confirmation,
|
||||||
|
puis fusion avec les données existantes — même identifiant = mise à jour,
|
||||||
|
donc réimporter deux fois ne crée pas de doublon et rien n'est effacé
|
||||||
|
- Les statistiques ne sont pas réimportées : elles sont recalculées à partir des
|
||||||
|
sessions. Elles figurent dans le fichier pour être exploitables telles quelles
|
||||||
|
|
||||||
### Historique des sessions
|
### Historique des sessions
|
||||||
- Sauvegarde des sessions avec date, score, notes
|
- Sauvegarde des sessions avec date, score, notes
|
||||||
- Visualisation des sessions passées
|
- Visualisation des sessions passées
|
||||||
@@ -119,3 +134,4 @@ session_list_item.dart Item de liste représentant une session
|
|||||||
history_chart.dart Graphique d'évolution des 10 dernières sessions
|
history_chart.dart Graphique d'évolution des 10 dernières sessions
|
||||||
statistics_screen.dart Écran statistiques avec filtrage par période
|
statistics_screen.dart Écran statistiques avec filtrage par période
|
||||||
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
||||||
|
backup_service.dart Export/import JSON des sessions, stats et armurerie
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
+1
-1
@@ -27,7 +27,7 @@ class BullyApp extends StatelessWidget {
|
|||||||
Locale('fr', 'FR'), // Français
|
Locale('fr', 'FR'), // Français
|
||||||
],
|
],
|
||||||
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
home: const MainNavigationHolder(),
|
home: MainNavigationHolder(key: mainNavKey),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -590,6 +590,16 @@ class DatabaseHelper {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
|
||||||
|
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||||
|
final db = await database;
|
||||||
|
final maps = await db.query(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
orderBy: 'date DESC',
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final maps = await db.query(
|
final maps = await db.query(
|
||||||
|
|||||||
@@ -131,6 +131,29 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getStatistics();
|
return await _databaseHelper.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enregistre une session déjà construite (import de sauvegarde).
|
||||||
|
/// Les identifiants existants sont écrasés : réimporter deux fois la même
|
||||||
|
/// sauvegarde ne crée pas de doublons.
|
||||||
|
Future<void> saveSession(Session session) async {
|
||||||
|
await _databaseHelper.insertSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie une image dans le dossier des cibles de l'app (import de sauvegarde).
|
||||||
|
Future<String> saveImageBytes(List<int> bytes, String extension) async {
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
|
||||||
|
|
||||||
|
if (!await imagesDir.exists()) {
|
||||||
|
await imagesDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileName = '${_uuid.v4()}$extension';
|
||||||
|
final destPath = path.join(imagesDir.path, fileName);
|
||||||
|
await File(destPath).writeAsBytes(bytes);
|
||||||
|
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
|
|
||||||
String generateId() {
|
String generateId() {
|
||||||
return _uuid.v4();
|
return _uuid.v4();
|
||||||
}
|
}
|
||||||
@@ -170,6 +193,11 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getAllWeapons();
|
return await _databaseHelper.getAllWeapons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enregistre une arme déjà construite (import de sauvegarde).
|
||||||
|
Future<void> saveWeapon(Weapon weapon) async {
|
||||||
|
await _databaseHelper.insertWeapon(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateWeapon(Weapon weapon) async {
|
Future<void> updateWeapon(Weapon weapon) async {
|
||||||
await _databaseHelper.updateWeapon(weapon);
|
await _databaseHelper.updateWeapon(weapon);
|
||||||
}
|
}
|
||||||
@@ -208,6 +236,16 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tout l'historique de maintenance de l'armurerie (export de sauvegarde).
|
||||||
|
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||||
|
return await _databaseHelper.getAllMaintenance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre une entrée de maintenance déjà construite (import).
|
||||||
|
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
||||||
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteMaintenanceEntry(String id) async {
|
Future<void> deleteMaintenanceEntry(String id) async {
|
||||||
await _databaseHelper.deleteMaintenance(id);
|
await _databaseHelper.deleteMaintenance(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting).
|
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
|
||||||
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
||||||
void clearShots() {
|
void clearShots() {
|
||||||
_shots.clear();
|
_shots.clear();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles.
|
/// Écran principal de Synthèse et d'analyse - Interface centrale de traitement des cibles.
|
||||||
///
|
///
|
||||||
/// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés.
|
/// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés.
|
||||||
/// Permet le calcul des scores et statistiques de groupement (Plotting).
|
/// Permet le calcul des scores et statistiques de groupement (Synthèse), et
|
||||||
|
/// c'est de là que l'on termine la session. L'ajout des impacts, lui, se fait
|
||||||
|
/// dans l'éditeur d'impacts plein écran.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -9,6 +11,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../main_navigation_holder.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
@@ -25,7 +28,6 @@ import 'widgets/target_overlay.dart';
|
|||||||
import 'widgets/target_calibration.dart';
|
import 'widgets/target_calibration.dart';
|
||||||
import 'widgets/score_card.dart';
|
import 'widgets/score_card.dart';
|
||||||
import 'widgets/grouping_stats.dart';
|
import 'widgets/grouping_stats.dart';
|
||||||
import 'widgets/shot_details_sheet.dart';
|
|
||||||
|
|
||||||
class AnalysisScreen extends StatelessWidget {
|
class AnalysisScreen extends StatelessWidget {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
@@ -110,16 +112,15 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
bool _isCalibrating = true;
|
bool _isCalibrating = true;
|
||||||
bool _isAtBottom = false;
|
bool _isAtBottom = false;
|
||||||
|
|
||||||
|
// Affichage du réglage manuel de l'espacement des anneaux.
|
||||||
|
bool _showSpacing = false;
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final TransformationController _transformationController =
|
|
||||||
TransformationController();
|
|
||||||
final GlobalKey _imageKey = GlobalKey();
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
double _currentZoomScale = 1.0;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_transformationController.addListener(_onTransformChanged);
|
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,32 +138,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_transformationController.removeListener(_onTransformChanged);
|
|
||||||
_transformationController.dispose();
|
|
||||||
_scrollController.removeListener(_onScroll);
|
_scrollController.removeListener(_onScroll);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTransformChanged() {
|
/// Repasse en mode calibration.
|
||||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
|
||||||
if (scale != _currentZoomScale) {
|
|
||||||
setState(() {
|
|
||||||
_currentZoomScale = scale;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
|
|
||||||
///
|
///
|
||||||
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
|
/// La cible du mode Synthèse est désormais un élément fixe (aucun zoom à
|
||||||
/// persiste dans le TransformationController et se réapplique au retour,
|
/// réinitialiser) : on se contente donc de rebasculer l'état.
|
||||||
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
|
|
||||||
/// d'une transformation identité (zoom 1.0).
|
|
||||||
void _enterCalibration() {
|
void _enterCalibration() {
|
||||||
_transformationController.value = Matrix4.identity();
|
setState(() {
|
||||||
_currentZoomScale = 1.0;
|
_isCalibrating = true;
|
||||||
setState(() => _isCalibrating = true);
|
// La calibration est reconstruite à neuf (espacement manuel désactivé) :
|
||||||
|
// on aligne l'état du panneau pour ne pas afficher un mode inactif.
|
||||||
|
_showSpacing = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
||||||
@@ -171,10 +162,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
||||||
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
||||||
///
|
///
|
||||||
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
|
/// Au retour, quel que soit le résultat (validation OU retour arrière), on
|
||||||
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
/// revient TOUJOURS sur la Synthèse. L'éditeur d'impacts n'est ouvert que
|
||||||
|
/// depuis la Synthèse : il doit donc y ramener, jamais sur la calibration.
|
||||||
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
||||||
final validated = await Navigator.of(context).push<bool>(
|
await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
||||||
value: provider,
|
value: provider,
|
||||||
@@ -185,13 +177,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (validated == true) {
|
setState(() {
|
||||||
setState(() {
|
_isCalibrating = false;
|
||||||
_isCalibrating = false;
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
_enterCalibration();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
||||||
@@ -204,6 +192,181 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
return widget.originalImagePath ?? provider.imagePath!;
|
return widget.originalImagePath ?? provider.imagePath!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Panneau de réglages de la calibration (taille + espacement).
|
||||||
|
///
|
||||||
|
/// Rendu AU-DESSUS de l'image (et non plus en surimpression) pour ne pas
|
||||||
|
/// masquer la cible. Les valeurs affichées viennent du provider ; les
|
||||||
|
/// modifications sont poussées dans l'état de [TargetCalibration] via sa clé.
|
||||||
|
Widget _buildCalibrationSettings(AnalysisProvider provider) {
|
||||||
|
final radius = provider.targetRadius.clamp(
|
||||||
|
TargetCalibrationState.minRadius,
|
||||||
|
TargetCalibrationState.maxRadius,
|
||||||
|
);
|
||||||
|
final spacing =
|
||||||
|
(provider.targetRadius > 0
|
||||||
|
? provider.targetInnerRadius / provider.targetRadius
|
||||||
|
: 0.1)
|
||||||
|
.clamp(
|
||||||
|
TargetCalibrationState.minSpacing,
|
||||||
|
TargetCalibrationState.maxSpacing,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Taille',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.zoom_out, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: radius,
|
||||||
|
min: TargetCalibrationState.minRadius,
|
||||||
|
max: TargetCalibrationState.maxRadius,
|
||||||
|
activeColor: AppTheme.primaryColor,
|
||||||
|
onChanged: (value) =>
|
||||||
|
_calibrationKey.currentState?.setRadius(value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.zoom_in, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Options d\'espacement avancées',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: _showSpacing,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showSpacing = value);
|
||||||
|
_calibrationKey.currentState?.setSpacingMode(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_showSpacing)
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Espacement',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.compress, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: spacing,
|
||||||
|
min: TargetCalibrationState.minSpacing,
|
||||||
|
max: TargetCalibrationState.maxSpacing,
|
||||||
|
activeColor: Colors.orange,
|
||||||
|
onChanged: (value) =>
|
||||||
|
_calibrationKey.currentState?.setSpacingRatio(value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.expand, size: 16),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
|
tooltip: 'Réinitialiser l\'espacement',
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
onPressed: () =>
|
||||||
|
_calibrationKey.currentState?.resetSpacing(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indication affichée sous le titre en mode Synthèse.
|
||||||
|
///
|
||||||
|
/// Rien n'indiquait comment ajouter un impact une fois la calibration
|
||||||
|
/// validée : ce rappel pointe vers le geste (toucher la cible).
|
||||||
|
Widget _buildSyntheseHint(AnalysisProvider provider) {
|
||||||
|
final hasShots = provider.shotCount > 0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
hasShots ? Icons.touch_app : Icons.add_location_alt,
|
||||||
|
size: 16,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
hasShots
|
||||||
|
? 'Touchez la cible pour modifier vos impacts'
|
||||||
|
: 'Touchez la cible pour placer vos impacts',
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bouton flottant du bas de l'écran.
|
||||||
|
///
|
||||||
|
/// En calibration : VALIDER. En synthèse : tant qu'aucun impact n'est placé,
|
||||||
|
/// on ne propose pas de terminer la session mais de placer un impact (le
|
||||||
|
/// bouton ouvre l'éditeur, comme un tap sur la cible).
|
||||||
|
Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) {
|
||||||
|
if (_isCalibrating) {
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
// Même bouton bleu flottant que « TERMINER LA SESSION » :
|
||||||
|
// on fige la calibration puis on bascule sur la Synthèse.
|
||||||
|
onPressed: () {
|
||||||
|
_calibrationKey.currentState?.commitCalibration();
|
||||||
|
setState(() => _isCalibrating = false);
|
||||||
|
},
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('VALIDER'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.shotCount == 0) {
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: () => _openImpactEditor(provider),
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
icon: const Icon(Icons.add_location_alt),
|
||||||
|
label: const Text('PLACER UN IMPACT'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: () => _showSaveSessionDialog(context, provider),
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('TERMINER LA SESSION'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<AnalysisProvider>();
|
final provider = context.watch<AnalysisProvider>();
|
||||||
@@ -212,10 +375,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
? sessionProvider.targetCount + 1
|
? sessionProvider.targetCount + 1
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting';
|
final titlePrefix = _isCalibrating ? 'Calibration' : 'Synthèse';
|
||||||
final title = targetNumber != null
|
final title = targetNumber != null
|
||||||
? '$titlePrefix - Cible $targetNumber'
|
? '$titlePrefix - Cible $targetNumber'
|
||||||
: (_isCalibrating ? 'Calibration' : 'Plotting du Tir');
|
: (_isCalibrating ? 'Calibration' : 'Synthèse du Tir');
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -239,92 +402,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Retour Plotting -> Calibration : on réinitialise le zoom.
|
// Retour Synthèse -> Calibration : on réinitialise le zoom.
|
||||||
_enterCalibration();
|
_enterCalibration();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actions: [
|
actions: const [],
|
||||||
// Remise à zéro des impacts : efface tous les impacts en un clic,
|
|
||||||
// sans modifier la calibration (centre, rayon, anneaux).
|
|
||||||
if (!_isCalibrating)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Effacer tous les impacts',
|
|
||||||
onPressed: () => provider.clearShots(),
|
|
||||||
),
|
|
||||||
// Nuage d'export vers le backend IA : visible uniquement si l'analyse
|
|
||||||
// a réussi ET que l'utilisateur a activé l'option dans les Paramètres.
|
|
||||||
if (!_isCalibrating)
|
|
||||||
FutureBuilder<bool>(
|
|
||||||
future: WalletIdentityService().isUploadEnabled(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final isEnabled = snapshot.data ?? false;
|
|
||||||
if (!isEnabled ||
|
|
||||||
provider.state != AnalysisState.success) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return IconButton(
|
|
||||||
icon: const Icon(Icons.cloud_upload),
|
|
||||||
tooltip: 'Exporter pour IA',
|
|
||||||
onPressed: () async {
|
|
||||||
final p = context.read<AnalysisProvider>();
|
|
||||||
if (p.state != AnalysisState.success) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Exportation en cours...')),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Métadonnées réelles de la session en cours (distance,
|
|
||||||
// arme) plutôt que les placeholders par défaut.
|
|
||||||
final sp = context.read<SessionProvider>();
|
|
||||||
final success = await p.exportToAiBackend(
|
|
||||||
sessionId: sp.activeSessionId,
|
|
||||||
distance: sp.distance,
|
|
||||||
weapon: sp.currentWeapon,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
if (success) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Export réussi vers le backend IA !'),
|
|
||||||
backgroundColor: AppTheme.successColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content:
|
|
||||||
Text(p.errorMessage ?? 'Erreur d\'export'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_isCalibrating)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
|
|
||||||
// puis on passe sur l'écran d'édition d'impacts plein écran
|
|
||||||
// (zoom fiable + placement). Le mode Plotting (lecture seule)
|
|
||||||
// s'affichera au retour si l'utilisateur valide.
|
|
||||||
_calibrationKey.currentState?.commitCalibration();
|
|
||||||
_openImpactEditor(context.read<AnalysisProvider>());
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
'VALIDER',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -332,21 +415,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
// Plus de bloc vide au-dessus de l'image : l'indicateur n'occupe
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
// de la place que pendant le chargement.
|
||||||
child: Column(
|
if (provider.state == AnalysisState.loading)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const Padding(
|
||||||
children: [
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||||
if (provider.state == AnalysisState.loading)
|
child: Center(child: CircularProgressIndicator()),
|
||||||
const Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
// Réglages de calibration : au-dessus de la photo pour ne rien
|
||||||
|
// masquer de la cible.
|
||||||
|
if (_isCalibrating)
|
||||||
|
_buildCalibrationSettings(provider)
|
||||||
|
else
|
||||||
|
_buildSyntheseHint(provider),
|
||||||
|
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: provider.imageAspectRatio,
|
aspectRatio: provider.imageAspectRatio,
|
||||||
@@ -416,27 +498,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Card(
|
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.18),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.edit_location_alt,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
title: const Text('Modifier les impacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
|
|
||||||
),
|
|
||||||
trailing: const Icon(
|
|
||||||
Icons.open_in_full,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
// Rouvre l'éditeur plein écran en partageant le provider.
|
|
||||||
onTap: () =>
|
|
||||||
_openImpactEditor(context.read<AnalysisProvider>()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Card(
|
Card(
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -462,6 +523,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
shotCount: provider.shotCount,
|
shotCount: provider.shotCount,
|
||||||
scoreResult: provider.scoreResult,
|
scoreResult: provider.scoreResult,
|
||||||
targetType: provider.targetType!,
|
targetType: provider.targetType!,
|
||||||
|
// Cumul de la session : cibles déjà validées + cible
|
||||||
|
// en cours (absent hors session).
|
||||||
|
sessionTotalScore: sessionProvider.isSessionActive
|
||||||
|
? sessionProvider.totalSessionScore +
|
||||||
|
provider.totalScore
|
||||||
|
: null,
|
||||||
|
sessionTargetCount:
|
||||||
|
sessionProvider.targetCount + 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (provider.groupingResult != null &&
|
if (provider.groupingResult != null &&
|
||||||
@@ -574,15 +643,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
padding: _isAtBottom
|
padding: _isAtBottom
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: const EdgeInsets.all(16.0),
|
: const EdgeInsets.all(16.0),
|
||||||
child: _isCalibrating
|
child: _buildBottomAction(context, provider),
|
||||||
? const SizedBox.shrink()
|
|
||||||
: FloatingActionButton.extended(
|
|
||||||
onPressed: () =>
|
|
||||||
_showSaveSessionDialog(context, provider),
|
|
||||||
backgroundColor: AppTheme.primaryColor,
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text('TERMINER LA SESSION'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -604,22 +665,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Affichage du plotting en LECTURE SEULE.
|
/// Affichage de la synthèse en LECTURE SEULE.
|
||||||
///
|
///
|
||||||
/// L'édition (ajout / déplacement / suppression) se fait désormais
|
/// La cible est un élément d'écran FIXE : elle ne se déplace pas et ne se
|
||||||
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
|
/// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible
|
||||||
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
|
/// ouvre directement l'éditeur d'impacts plein écran (ImpactEditorScreen),
|
||||||
/// Le tap sur un impact ouvre simplement ses détails.
|
/// exactement comme le faisait l'ancien bouton « Modifier les impacts ».
|
||||||
|
/// C'est là que se fait toute l'édition (ajout / déplacement / suppression),
|
||||||
|
/// avec un zoom fiable.
|
||||||
Widget _buildReadOnlyPlotImage(
|
Widget _buildReadOnlyPlotImage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AnalysisProvider provider,
|
AnalysisProvider provider,
|
||||||
) {
|
) {
|
||||||
return InteractiveViewer(
|
return GestureDetector(
|
||||||
transformationController: _transformationController,
|
behavior: HitTestBehavior.opaque,
|
||||||
minScale: 1.0,
|
onTap: () => _openImpactEditor(context.read<AnalysisProvider>()),
|
||||||
maxScale: 10.0,
|
|
||||||
boundaryMargin: const EdgeInsets.all(80),
|
|
||||||
panEnabled: true,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Image.file(
|
Image.file(
|
||||||
@@ -635,10 +695,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
targetType: provider.targetType!,
|
targetType: provider.targetType!,
|
||||||
shots: provider.shots,
|
shots: provider.shots,
|
||||||
showRings: true,
|
showRings: true,
|
||||||
zoomScale: _currentZoomScale,
|
zoomScale: 1.0,
|
||||||
// Lecture seule : tap sur impact -> détails (consultation).
|
|
||||||
onShotTapped: (shot) =>
|
|
||||||
showShotDetailsSheet(context, provider, shot),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -646,112 +703,278 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
|
Future<void> _showSaveSessionDialog(
|
||||||
|
BuildContext context,
|
||||||
|
AnalysisProvider provider,
|
||||||
|
) async {
|
||||||
|
// L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT
|
||||||
|
// d'ouvrir la popup : elle décide de la présence du bouton d'export.
|
||||||
|
final canExport =
|
||||||
|
await WalletIdentityService().isUploadEnabled() &&
|
||||||
|
provider.state == AnalysisState.success;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Session Terminee'),
|
// Protège des petits écrans / grandes polices : la popup défile au
|
||||||
|
// lieu de déborder.
|
||||||
|
scrollable: true,
|
||||||
|
// En-tête bleu pleine largeur : le titre occupe toute la bande, d'où
|
||||||
|
// les paddings mis à zéro.
|
||||||
|
titlePadding: EdgeInsets.zero,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
title: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flag, color: Colors.white),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Session terminée',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Les boutons sont dans le contenu (et non dans `actions`) pour être
|
||||||
|
// tous à la même largeur, alignés les uns sous les autres.
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Nombre de tirs: ${provider.shotCount}'),
|
_buildDialogRecap(context, provider),
|
||||||
Text('Score total: ${provider.totalScore}'),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Voulez-vous enregistrer cette session ?'),
|
const Text('Voulez-vous enregistrer cette session ?'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDialogButton(
|
||||||
|
icon: const Icon(Icons.add_a_photo, color: Colors.white),
|
||||||
|
label: 'AJOUTER UNE CIBLE',
|
||||||
|
color: AppTheme.secondaryColor,
|
||||||
|
onPressed: () => _saveAndAddTarget(context, provider),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildDialogButton(
|
||||||
|
icon: const Icon(Icons.save, color: Colors.white),
|
||||||
|
label: 'TERMINER TOUT',
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
onPressed: () => _finishSession(context, provider),
|
||||||
|
),
|
||||||
|
// Bouton d'export : uniquement si l'entraînement IA est autorisé.
|
||||||
|
if (canExport) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildDialogButton(
|
||||||
|
icon: Image.asset(
|
||||||
|
'assets/icons/cloud_save.png',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
// L'icône est un trait noir : on la recolore en blanc pour
|
||||||
|
// qu'elle ressorte sur le bouton.
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
label: 'TERMINER TOUT ET EXPORTER',
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
onPressed: () =>
|
||||||
|
_finishSession(context, provider, export: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
|
||||||
|
final path = _backCropImagePath(provider);
|
||||||
|
final type = provider.targetType!;
|
||||||
|
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => CropScreen(
|
||||||
|
imagePath: path,
|
||||||
|
targetType: type,
|
||||||
|
initialScale: widget.cropScale,
|
||||||
|
initialOffset: widget.cropOffset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('ANNULER'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
);
|
||||||
onPressed: () {
|
}
|
||||||
Navigator.pop(context);
|
|
||||||
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
|
|
||||||
final path = _backCropImagePath(provider);
|
|
||||||
final type = provider.targetType!;
|
|
||||||
|
|
||||||
Navigator.pushReplacement(
|
/// Rappel chiffré (tirs / score) en tête de la popup de fin de session.
|
||||||
context,
|
Widget _buildDialogRecap(BuildContext context, AnalysisProvider provider) {
|
||||||
MaterialPageRoute(
|
return Container(
|
||||||
builder: (context) => CropScreen(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
imagePath: path,
|
decoration: BoxDecoration(
|
||||||
targetType: type,
|
color: AppTheme.primaryColor.withValues(alpha: 0.10),
|
||||||
initialScale: widget.cropScale,
|
borderRadius: BorderRadius.circular(12),
|
||||||
initialOffset: widget.cropOffset,
|
),
|
||||||
),
|
child: Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
);
|
children: [
|
||||||
},
|
_buildRecapValue(context, '${provider.shotCount}', 'Tirs'),
|
||||||
child: const Text('ANNULER'),
|
_buildRecapValue(context, '${provider.totalScore}', 'Score total'),
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
final sessionProvider = context.read<SessionProvider>();
|
|
||||||
final analysis = await provider.saveSession(
|
|
||||||
sessionId: sessionProvider.activeSessionId,
|
|
||||||
weaponName: sessionProvider.currentWeapon,
|
|
||||||
weaponId: sessionProvider.currentWeaponId,
|
|
||||||
distance: sessionProvider.distance,
|
|
||||||
date: sessionProvider.sessionDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
sessionProvider.addAnalysis(analysis);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.pushAndRemoveUntil(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const CaptureScreen(),
|
|
||||||
),
|
|
||||||
(route) => route.isFirst,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('AJOUTER UNE CIBLE'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
final sessionProvider = context.read<SessionProvider>();
|
|
||||||
await provider.saveSession(
|
|
||||||
sessionId: sessionProvider.activeSessionId,
|
|
||||||
weaponName: sessionProvider.currentWeapon,
|
|
||||||
weaponId: sessionProvider.currentWeaponId,
|
|
||||||
distance: sessionProvider.distance,
|
|
||||||
date: sessionProvider.sessionDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
sessionProvider.endSession();
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('TERMINER TOUT'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildRecapValue(BuildContext context, String value, String label) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bouton pleine largeur de la popup, aux couleurs du thème.
|
||||||
|
Widget _buildDialogButton({
|
||||||
|
required Widget icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
icon: icon,
|
||||||
|
label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: color,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre la cible courante et enchaîne sur une nouvelle capture.
|
||||||
|
Future<void> _saveAndAddTarget(
|
||||||
|
BuildContext context,
|
||||||
|
AnalysisProvider provider,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
|
final analysis = await provider.saveSession(
|
||||||
|
sessionId: sessionProvider.activeSessionId,
|
||||||
|
weaponName: sessionProvider.currentWeapon,
|
||||||
|
weaponId: sessionProvider.currentWeaponId,
|
||||||
|
distance: sessionProvider.distance,
|
||||||
|
date: sessionProvider.sessionDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionProvider.addAnalysis(analysis);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushAndRemoveUntil(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const CaptureScreen()),
|
||||||
|
(route) => route.isFirst,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clôture la session et revient sur l'onglet Statistiques.
|
||||||
|
///
|
||||||
|
/// Avec [export], la cible est en plus envoyée au backend d'entraînement IA.
|
||||||
|
/// L'enregistrement local reste prioritaire : un échec d'export n'empêche
|
||||||
|
/// jamais la session d'être sauvegardée.
|
||||||
|
Future<void> _finishSession(
|
||||||
|
BuildContext context,
|
||||||
|
AnalysisProvider provider, {
|
||||||
|
bool export = false,
|
||||||
|
}) async {
|
||||||
|
// Messenger et session capturés AVANT les await : le contexte de la popup
|
||||||
|
// ne sera plus valide ensuite.
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provider.saveSession(
|
||||||
|
sessionId: sessionProvider.activeSessionId,
|
||||||
|
weaponName: sessionProvider.currentWeapon,
|
||||||
|
weaponId: sessionProvider.currentWeaponId,
|
||||||
|
distance: sessionProvider.distance,
|
||||||
|
date: sessionProvider.sessionDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool? exportSucceeded;
|
||||||
|
if (export) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Exportation en cours...')),
|
||||||
|
);
|
||||||
|
exportSucceeded = await provider.exportToAiBackend(
|
||||||
|
sessionId: sessionProvider.activeSessionId,
|
||||||
|
distance: sessionProvider.distance,
|
||||||
|
weapon: sessionProvider.currentWeapon,
|
||||||
|
);
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
sessionProvider.endSession();
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
// Fin de session : on atterrit sur les statistiques.
|
||||||
|
openMainTab(mainTabStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportSucceeded != null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
exportSucceeded
|
||||||
|
? 'Export réussi vers le backend IA !'
|
||||||
|
: (provider.errorMessage ?? 'Erreur d\'export'),
|
||||||
|
),
|
||||||
|
backgroundColor: exportSucceeded
|
||||||
|
? AppTheme.successColor
|
||||||
|
: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,8 +7,11 @@
|
|||||||
/// scroll vertical ou une transformation parente.
|
/// scroll vertical ou une transformation parente.
|
||||||
///
|
///
|
||||||
/// Interactions :
|
/// Interactions :
|
||||||
/// - Tap sur zone vide -> ajoute un impact
|
/// - Tap -> ajoute TOUJOURS un impact, même juste à côté
|
||||||
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
|
/// (ou par-dessus) un impact existant. Aucun tap
|
||||||
|
/// n'ouvre d'édition de score : on peut donc
|
||||||
|
/// placer un impact au pouce près sans être
|
||||||
|
/// interrompu par une popup.
|
||||||
/// - Appui long + glisser -> déplace l'impact
|
/// - Appui long + glisser -> déplace l'impact
|
||||||
///
|
///
|
||||||
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
||||||
@@ -20,10 +23,10 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/shot.dart';
|
import '../../data/models/shot.dart';
|
||||||
import 'analysis_provider.dart';
|
import 'analysis_provider.dart';
|
||||||
import 'widgets/target_overlay.dart';
|
import 'widgets/target_overlay.dart';
|
||||||
import 'widgets/shot_details_sheet.dart';
|
|
||||||
|
|
||||||
class ImpactEditorScreen extends StatefulWidget {
|
class ImpactEditorScreen extends StatefulWidget {
|
||||||
const ImpactEditorScreen({super.key});
|
const ImpactEditorScreen({super.key});
|
||||||
@@ -72,8 +75,11 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
||||||
|
///
|
||||||
|
/// Utilisé uniquement par l'appui long (déplacement) : le tap simple, lui,
|
||||||
|
/// ajoute toujours un impact sans chercher à en sélectionner un.
|
||||||
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
||||||
{double tolerance = 0.04}) {
|
{double tolerance = 0.06}) {
|
||||||
Shot? closest;
|
Shot? closest;
|
||||||
double minDistance = double.infinity;
|
double minDistance = double.infinity;
|
||||||
for (final shot in provider.shots) {
|
for (final shot in provider.shots) {
|
||||||
@@ -99,16 +105,29 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|||||||
title: Text('Placement des impacts (${provider.shotCount})'),
|
title: Text('Placement des impacts (${provider.shotCount})'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
tooltip: 'Retour à la calibration',
|
tooltip: 'Retour à la synthèse',
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
// Corbeille (efface tous les impacts) + bouton bleu flottant VALIDER.
|
||||||
|
// heroTag distinct sur chaque FAB pour éviter le conflit de Hero.
|
||||||
|
floatingActionButton: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FloatingActionButton(
|
||||||
|
heroTag: 'reset_impacts',
|
||||||
|
onPressed: () => provider.clearShots(),
|
||||||
|
backgroundColor: Colors.grey.shade800,
|
||||||
|
tooltip: 'Effacer tous les impacts',
|
||||||
|
child: const Icon(Icons.delete),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'validate_impacts',
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text(
|
backgroundColor: AppTheme.primaryColor,
|
||||||
'VALIDER',
|
icon: const Icon(Icons.check),
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
label: const Text('VALIDER'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -120,7 +139,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|||||||
color: Colors.white10,
|
color: Colors.white10,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
|
'Tap : ajouter un impact • Appui long : déplacer • Pincer : zoomer',
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -137,24 +156,20 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
// TAP : éditer si on touche un impact, sinon ajouter.
|
// TAP : ajoute un impact, sans exception. Même collé à un
|
||||||
|
// impact existant, le tap crée le nouvel impact au lieu
|
||||||
|
// d'ouvrir l'édition du score.
|
||||||
onTapUp: (details) {
|
onTapUp: (details) {
|
||||||
if (_movingShotId != null) return;
|
if (_movingShotId != null) return;
|
||||||
final rel = _toImageRelative(details.globalPosition);
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
if (rel == null) return;
|
if (rel == null) return;
|
||||||
|
provider.addShot(rel.dx, rel.dy);
|
||||||
final hit = _hitTestShot(provider, rel);
|
|
||||||
if (hit != null) {
|
|
||||||
showShotDetailsSheet(context, provider, hit);
|
|
||||||
} else {
|
|
||||||
provider.addShot(rel.dx, rel.dy);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
final rel = _toImageRelative(details.globalPosition);
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
if (rel == null) return;
|
if (rel == null) return;
|
||||||
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
|
final hit = _hitTestShot(provider, rel);
|
||||||
if (hit != null) {
|
if (hit != null) {
|
||||||
setState(() => _movingShotId = hit.id);
|
setState(() => _movingShotId = hit.id);
|
||||||
}
|
}
|
||||||
@@ -190,10 +205,10 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|||||||
shots: provider.shots,
|
shots: provider.shots,
|
||||||
showRings: true,
|
showRings: true,
|
||||||
zoomScale: _currentZoomScale,
|
zoomScale: _currentZoomScale,
|
||||||
// L'ajout et la sélection sont gérés par le
|
// Aucun onShotTapped : les impacts ne captent plus le
|
||||||
// GestureDetector parent ci-dessus.
|
// toucher, tout va au GestureDetector parent qui
|
||||||
onShotTapped: (shot) =>
|
// ajoute un impact (y compris pile sur un impact
|
||||||
showShotDetailsSheet(context, provider, shot),
|
// existant).
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ class ScoreCard extends StatelessWidget {
|
|||||||
final ScoreResult? scoreResult;
|
final ScoreResult? scoreResult;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
|
||||||
|
/// Score cumulé de la session (cibles déjà validées + cible en cours).
|
||||||
|
///
|
||||||
|
/// null hors session : le bandeau de session n'est alors pas affiché.
|
||||||
|
final int? sessionTotalScore;
|
||||||
|
|
||||||
|
/// Nombre de cibles comptabilisées dans [sessionTotalScore].
|
||||||
|
final int sessionTargetCount;
|
||||||
|
|
||||||
const ScoreCard({
|
const ScoreCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.totalScore,
|
required this.totalScore,
|
||||||
required this.shotCount,
|
required this.shotCount,
|
||||||
this.scoreResult,
|
this.scoreResult,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
|
this.sessionTotalScore,
|
||||||
|
this.sessionTargetCount = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,13 +56,23 @@ class ScoreCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (sessionTotalScore != null) ...[
|
||||||
|
Flexible(child: _buildSessionBadge(context)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
MetricInfoButton(
|
MetricInfoButton(
|
||||||
title: 'Score',
|
title: 'Score',
|
||||||
explanations: [
|
explanations: [
|
||||||
MetricExplanation(
|
MetricExplanation(
|
||||||
'Total',
|
'Total',
|
||||||
'Somme des points de tous vos impacts, sur le maximum '
|
'Somme des points de tous vos impacts sur CETTE cible, '
|
||||||
'possible (nombre d\'impacts × $maxScore points).',
|
'sur le maximum possible (nombre d\'impacts × '
|
||||||
|
'$maxScore points).',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Session',
|
||||||
|
'Score cumulé de toutes les cibles de la session en '
|
||||||
|
'cours, cible affichée comprise.',
|
||||||
),
|
),
|
||||||
const MetricExplanation(
|
const MetricExplanation(
|
||||||
'Impacts',
|
'Impacts',
|
||||||
@@ -122,6 +142,30 @@ class ScoreCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bandeau compact rappelant le score total de la session en cours.
|
||||||
|
Widget _buildSessionBadge(BuildContext context) {
|
||||||
|
// Le nombre de cibles n'est rappelé qu'à partir de la deuxième : sur la
|
||||||
|
// première il n'apporte rien et allonge le bandeau pour rien.
|
||||||
|
final cibles = sessionTargetCount > 1 ? ' ($sessionTargetCount cibles)' : '';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Session : $sessionTotalScore pts$cibles',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildScoreStat(
|
Widget _buildScoreStat(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String label,
|
String label,
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/// Bottom sheet de détails d'un impact : modification du score et suppression.
|
|
||||||
///
|
|
||||||
/// Partagée entre l'écran d'analyse (consultation du plotting) et l'éditeur
|
|
||||||
/// d'impacts plein écran, qui opèrent sur le même AnalysisProvider.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../../data/models/shot.dart';
|
|
||||||
import '../analysis_provider.dart';
|
|
||||||
|
|
||||||
void showShotDetailsSheet(
|
|
||||||
BuildContext context,
|
|
||||||
AnalysisProvider provider,
|
|
||||||
Shot shot,
|
|
||||||
) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Impact #${provider.shots.indexOf(shot) + 1}',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'ID: ${shot.id}',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodySmall
|
|
||||||
?.copyWith(color: Colors.grey, fontSize: 10),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.score),
|
|
||||||
title: const Text('Modifier le score'),
|
|
||||||
trailing: DropdownButton<int>(
|
|
||||||
value: shot.score.clamp(0, 10),
|
|
||||||
items: List.generate(11, (index) => index)
|
|
||||||
.map(
|
|
||||||
(s) => DropdownMenuItem(
|
|
||||||
value: s,
|
|
||||||
child: Text(
|
|
||||||
'$s',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (newScore) {
|
|
||||||
if (newScore != null) {
|
|
||||||
provider.updateShotScore(shot.id, newScore);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
provider.removeShot(shot.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
label: const Text(
|
|
||||||
'SUPPRIMER',
|
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
|
|
||||||
class TargetCalibration extends StatefulWidget {
|
class TargetCalibration extends StatefulWidget {
|
||||||
@@ -44,6 +43,15 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TargetCalibrationState extends State<TargetCalibration> {
|
class TargetCalibrationState extends State<TargetCalibration> {
|
||||||
|
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
|
||||||
|
/// débordement incontrôlé).
|
||||||
|
static const double minRadius = 0.3;
|
||||||
|
static const double maxRadius = 0.95;
|
||||||
|
|
||||||
|
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
|
||||||
|
static const double minSpacing = 0.01;
|
||||||
|
static const double maxSpacing = 0.70;
|
||||||
|
|
||||||
late double _centerX;
|
late double _centerX;
|
||||||
late double _centerY;
|
late double _centerY;
|
||||||
late double _radius;
|
late double _radius;
|
||||||
@@ -154,189 +162,116 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final size = constraints.biggest;
|
final size = constraints.biggest;
|
||||||
|
|
||||||
return Stack(
|
// Les réglages (taille / espacement) sont rendus par l'écran hôte
|
||||||
children: [
|
// AU-DESSUS de l'image : rien ne vient masquer la cible ici.
|
||||||
GestureDetector(
|
return GestureDetector(
|
||||||
onScaleStart: (details) {
|
onScaleStart: (details) {
|
||||||
_baseRadiusBeforeScale = _radius;
|
_baseRadiusBeforeScale = _radius;
|
||||||
final tapX = details.localFocalPoint.dx / size.width;
|
final tapX = details.localFocalPoint.dx / size.width;
|
||||||
final tapY = details.localFocalPoint.dy / size.height;
|
final tapY = details.localFocalPoint.dy / size.height;
|
||||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||||
|
|
||||||
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = true;
|
_isDraggingCenter = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
||||||
onScaleEnd: (_) => _onScaleEnd(),
|
onScaleEnd: (_) => _onScaleEnd(),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
size: size,
|
size: size,
|
||||||
painter: _CalibrationPainter(
|
painter: _CalibrationPainter(
|
||||||
centerX: _centerX,
|
centerX: _centerX,
|
||||||
centerY: _centerY,
|
centerY: _centerY,
|
||||||
radius: _radius,
|
radius: _radius,
|
||||||
innerRadius: _innerRadius,
|
innerRadius: _innerRadius,
|
||||||
ringCount: _ringCount,
|
ringCount: _ringCount,
|
||||||
ringRadii: _ringRadii,
|
ringRadii: _ringRadii,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
isDraggingCenter: _isDraggingCenter,
|
isDraggingCenter: _isDraggingCenter,
|
||||||
isDraggingRadius: false,
|
isDraggingRadius: false,
|
||||||
isDraggingInnerRadius: false,
|
isDraggingInnerRadius: false,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned(
|
|
||||||
top: 10,
|
|
||||||
left: 40,
|
|
||||||
right: 40,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black54,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Options d\'espacement avancées',
|
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
height: 28,
|
|
||||||
child: Switch(
|
|
||||||
value: _showEspacement,
|
|
||||||
activeThumbColor: const Color(0xFF00FF00),
|
|
||||||
onChanged: (bool value) {
|
|
||||||
setState(() {
|
|
||||||
_showEspacement = value;
|
|
||||||
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
|
|
||||||
if (!value) {
|
|
||||||
_initRingRadii(forceRecalculate: false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Slider pour la taille (toujours visible)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black54,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
|
||||||
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
|
|
||||||
value: _radius.clamp(0.3, 0.95),
|
|
||||||
min: 0.3,
|
|
||||||
max: 0.95,
|
|
||||||
activeColor: AppTheme.primaryColor,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_radius = value;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
|
|
||||||
_initRingRadii(forceRecalculate: _showEspacement);
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Affichage conditionnel du slider d'espacement orange
|
|
||||||
if (_showEspacement) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black54,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
|
||||||
const Icon(Icons.compress, color: Colors.white, size: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: _currentEspacementRatio,
|
|
||||||
min: 0.01,
|
|
||||||
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
|
|
||||||
max: 0.70,
|
|
||||||
activeColor: Colors.orange,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_currentEspacementRatio = value;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
_initRingRadii(forceRecalculate: true);
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.expand, color: Colors.white, size: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
if (_originalRingRadii != null) {
|
|
||||||
_initRingRadii(forceRecalculate: false);
|
|
||||||
if (_ringRadii.isNotEmpty) {
|
|
||||||
_currentEspacementRatio = _ringRadii.first;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_currentEspacementRatio = 0.1;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
_initRingRadii(forceRecalculate: true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
},
|
|
||||||
tooltip: 'Réinitialiser l\'espacement',
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
|
||||||
|
double get spacingRatio => _currentEspacementRatio;
|
||||||
|
|
||||||
|
/// Mode d'espacement manuel actif ou non.
|
||||||
|
bool get isSpacingModeEnabled => _showEspacement;
|
||||||
|
|
||||||
|
/// Applique une nouvelle taille globale (rayon normalisé).
|
||||||
|
void setRadius(double value) {
|
||||||
|
setState(() {
|
||||||
|
_radius = value.clamp(minRadius, maxRadius);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
// Si le mode avancé n'est pas coché, on applique la taille pure sans
|
||||||
|
// détruire le ratio d'origine.
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agrandit / réduit la cible de [deltaPixels] pixels.
|
||||||
|
///
|
||||||
|
/// La conversion utilise la plus petite dimension de [size], exactement comme
|
||||||
|
/// le painter, pour que le pas corresponde bien à un pixel à l'écran.
|
||||||
|
void adjustRadiusByPixels(double deltaPixels, Size size) {
|
||||||
|
final minDim = size.width < size.height ? size.width : size.height;
|
||||||
|
if (minDim <= 0) return;
|
||||||
|
setRadius(_radius + deltaPixels / minDim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active ou non le réglage manuel de l'espacement.
|
||||||
|
///
|
||||||
|
/// À la désactivation, on restaure la configuration d'usine.
|
||||||
|
void setSpacingMode(bool enabled) {
|
||||||
|
setState(() {
|
||||||
|
_showEspacement = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applique un nouvel espacement entre les anneaux.
|
||||||
|
void setSpacingRatio(double value) {
|
||||||
|
setState(() {
|
||||||
|
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restaure le vrai profil d'usine détecté sur l'image.
|
||||||
|
void resetSpacing() {
|
||||||
|
setState(() {
|
||||||
|
if (_originalRingRadii != null) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
if (_ringRadii.isNotEmpty) {
|
||||||
|
_currentEspacementRatio = _ringRadii.first;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_currentEspacementRatio = 0.1;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
Widget buildDirectionalControls(BuildContext context, Size size) {
|
Widget buildDirectionalControls(BuildContext context, Size size) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -348,7 +283,8 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildSignLabel('−'),
|
// Réduit la cible d'un pixel.
|
||||||
|
_buildSizeButton('−', () => adjustRadiusByPixels(-1, size)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -366,17 +302,27 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_buildSignLabel('+'),
|
// Agrandit la cible d'un pixel.
|
||||||
|
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Symbole purement décoratif affiché de part et d'autre de la croix.
|
/// Bouton − / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
|
||||||
Widget _buildSignLabel(String text) {
|
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
|
||||||
return Text(
|
return Container(
|
||||||
text,
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
|
child: IconButton(
|
||||||
|
icon: Text(
|
||||||
|
sign,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +361,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (details.pointerCount == 2) {
|
if (details.pointerCount == 2) {
|
||||||
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
|
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
_initRingRadii(forceRecalculate: _showEspacement);
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
} else if (_isDraggingCenter) {
|
} else if (_isDraggingCenter) {
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
// Désormais :
|
// Désormais :
|
||||||
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
||||||
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
||||||
// zones de tap localisées (deferToChild) placées sur chaque impact.
|
// zones de tap localisées (deferToChild) placées sur chaque impact —
|
||||||
|
// et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone
|
||||||
|
// de tap n'est créée : un tap pile sur un impact traverse jusqu'au parent
|
||||||
|
// au lieu d'être absorbé dans le vide.
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: false,
|
ignoring: false,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@@ -73,6 +76,8 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final onTapped = onShotTapped;
|
||||||
|
if (onTapped == null) return const SizedBox.expand();
|
||||||
return Stack(
|
return Stack(
|
||||||
children: shots.map((shot) {
|
children: shots.map((shot) {
|
||||||
final x = shot.x * constraints.maxWidth;
|
final x = shot.x * constraints.maxWidth;
|
||||||
@@ -89,7 +94,7 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
// Le reste de la surface reste donc disponible pour le
|
// Le reste de la surface reste donc disponible pour le
|
||||||
// pinch/pan de l'InteractiveViewer.
|
// pinch/pan de l'InteractiveViewer.
|
||||||
behavior: HitTestBehavior.deferToChild,
|
behavior: HitTestBehavior.deferToChild,
|
||||||
onTap: () => onShotTapped?.call(shot),
|
onTap: () => onTapped(shot),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: tapSize,
|
width: tapSize,
|
||||||
height: tapSize,
|
height: tapSize,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class CropScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CropScreenState extends State<CropScreen> {
|
class _CropScreenState extends State<CropScreen> {
|
||||||
|
// Bornes et pas de la rotation, partagés par la jauge et les boutons − / +.
|
||||||
|
static const double _minRotation = -15.0;
|
||||||
|
static const double _maxRotation = 15.0;
|
||||||
|
static const double _rotationStep = 0.1;
|
||||||
|
|
||||||
final ImageCropService _cropService = ImageCropService();
|
final ImageCropService _cropService = ImageCropService();
|
||||||
|
|
||||||
// États de transformation
|
// États de transformation
|
||||||
@@ -113,7 +118,7 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Alignez et pivotez la cible sur la croix',
|
'Zoomer au maximum puis aligner votre cible',
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -155,7 +160,8 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
|
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel,
|
||||||
|
// encadrée par les deux boutons de rotation fine.
|
||||||
_buildDirectionalPad(),
|
_buildDirectionalPad(),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -180,12 +186,13 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
// Les icônes de sens de rotation ont rejoint les boutons − / +
|
||||||
|
// de la croix directionnelle.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: _rotation,
|
value: _rotation,
|
||||||
min: -15.0,
|
min: _minRotation,
|
||||||
max: 15.0,
|
max: _maxRotation,
|
||||||
divisions: 300,
|
divisions: 300,
|
||||||
label: '${_rotation.toStringAsFixed(1)}°',
|
label: '${_rotation.toStringAsFixed(1)}°',
|
||||||
activeColor: const Color(0xFF1A73E8),
|
activeColor: const Color(0xFF1A73E8),
|
||||||
@@ -197,7 +204,6 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||||
@@ -363,12 +369,18 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||||||
// Les symboles « − » et « + » de part et d'autre sont purement décoratifs.
|
// Les boutons « − » et « + » de part et d'autre pivotent l'image de 0,1°.
|
||||||
Widget _buildDirectionalPad() {
|
Widget _buildDirectionalPad() {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildCropSignLabel('−'),
|
_buildRotationButton(
|
||||||
|
icon: Icons.rotate_left,
|
||||||
|
sign: '−',
|
||||||
|
iconFirst: true,
|
||||||
|
tooltip: 'Pivoter vers la gauche',
|
||||||
|
onPressed: () => _rotateBy(-_rotationStep),
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -386,7 +398,13 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
_buildCropSignLabel('+'),
|
_buildRotationButton(
|
||||||
|
icon: Icons.rotate_right,
|
||||||
|
sign: '+',
|
||||||
|
iconFirst: false,
|
||||||
|
tooltip: 'Pivoter vers la droite',
|
||||||
|
onPressed: () => _rotateBy(_rotationStep),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -404,10 +422,40 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCropSignLabel(String text) {
|
// Bouton de rotation : l'icône de sens est accolée au signe − / +.
|
||||||
return Text(
|
Widget _buildRotationButton({
|
||||||
text,
|
required IconData icon,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
|
required String sign,
|
||||||
|
required bool iconFirst,
|
||||||
|
required String tooltip,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
final content = [
|
||||||
|
Icon(icon, color: Colors.white70, size: 20),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
sign,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: iconFirst ? content : content.reversed.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +465,17 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pivote l'image de [delta] degrés, dans les mêmes bornes que la jauge.
|
||||||
|
///
|
||||||
|
/// La valeur est arrondie au dixième pour rester calée sur les crans de la
|
||||||
|
/// jauge (300 divisions sur 30°) et sur l'affichage.
|
||||||
|
void _rotateBy(double delta) {
|
||||||
|
setState(() {
|
||||||
|
final value = (_rotation + delta).clamp(_minRotation, _maxRotation);
|
||||||
|
_rotation = (value * 10).roundToDouble() / 10;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onScaleStart(ScaleStartDetails details) {
|
void _onScaleStart(ScaleStartDetails details) {
|
||||||
_baseScale = _scale;
|
_baseScale = _scale;
|
||||||
_startFocalPoint = details.focalPoint;
|
_startFocalPoint = details.focalPoint;
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import '../../data/repositories/session_repository.dart';
|
|||||||
import 'weapon_detail_screen.dart';
|
import 'weapon_detail_screen.dart';
|
||||||
|
|
||||||
class WeaponListScreen extends StatefulWidget {
|
class WeaponListScreen extends StatefulWidget {
|
||||||
const WeaponListScreen({super.key});
|
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
|
||||||
|
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
|
||||||
|
/// seul : sans ça, un import de sauvegarde resterait invisible).
|
||||||
|
final int refreshTick;
|
||||||
|
|
||||||
|
const WeaponListScreen({super.key, this.refreshTick = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||||
@@ -23,6 +28,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(WeaponListScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadWeapons() async {
|
Future<void> _loadWeapons() async {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final weapons = await repository.getWeapons();
|
final weapons = await repository.getWeapons();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../main_navigation_holder.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
@@ -14,7 +15,12 @@ import '../session/session_provider.dart';
|
|||||||
import 'widgets/stats_card.dart';
|
import 'widgets/stats_card.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
/// Incrémenté par la navigation à chaque ouverture de l'onglet Accueil
|
||||||
|
/// (l'écran est gardé vivant par l'IndexedStack : sans ça, un import de
|
||||||
|
/// sauvegarde resterait invisible sur le dashboard).
|
||||||
|
final int refreshTick;
|
||||||
|
|
||||||
|
const HomeScreen({super.key, this.refreshTick = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
@@ -34,6 +40,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HomeScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||||
|
_loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -230,6 +244,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
backgroundColor: AppTheme.successColor,
|
backgroundColor: AppTheme.successColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Fin de session : on bascule sur les statistiques.
|
||||||
|
openMainTab(mainTabStats);
|
||||||
},
|
},
|
||||||
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
|
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
|
||||||
),
|
),
|
||||||
@@ -339,7 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: StatsCard(
|
child: StatsCard(
|
||||||
icon: Icons.emoji_events,
|
icon: Icons.emoji_events,
|
||||||
title: 'Meilleur',
|
title: 'Meilleur score',
|
||||||
value: '${_stats!['bestScore']}',
|
value: '${_stats!['bestScore']}',
|
||||||
color: AppTheme.successColor,
|
color: AppTheme.successColor,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../../core/widgets/metric_info_button.dart';
|
import '../../core/widgets/metric_info_button.dart';
|
||||||
import '../../data/models/session.dart';
|
import '../../data/models/session.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import '../../services/backup_service.dart';
|
||||||
import '../../services/statistics_service.dart';
|
import '../../services/statistics_service.dart';
|
||||||
|
|
||||||
class StatisticsScreen extends StatefulWidget {
|
class StatisticsScreen extends StatefulWidget {
|
||||||
@@ -43,6 +48,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
bool _showingB = false; // false = on affiche A, true = on affiche B
|
bool _showingB = false; // false = on affiche A, true = on affiche B
|
||||||
bool get _compareMode => _compareA != null && _compareB != null;
|
bool get _compareMode => _compareA != null && _compareB != null;
|
||||||
|
|
||||||
|
// --- Sauvegarde (export/import JSON) ---
|
||||||
|
bool _isBackupBusy = false;
|
||||||
|
final GlobalKey _exportButtonKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -374,6 +383,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
_buildBiasWarning(),
|
_buildBiasWarning(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
|
||||||
|
// 5. SAUVEGARDE : export/import de toutes les données
|
||||||
|
_buildBackupSection(),
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -882,6 +896,263 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SAUVEGARDE : EXPORT / IMPORT JSON ---
|
||||||
|
|
||||||
|
Widget _buildBackupSection() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.save_alt, color: theme.textTheme.titleMedium?.color, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Sauvegarde',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textTheme.titleMedium?.color,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Exporte toutes tes sessions, tes stats et ton armurerie dans un '
|
||||||
|
'fichier JSON, à envoyer où tu veux. L\'import fusionne le fichier '
|
||||||
|
'avec tes données actuelles (rien n\'est effacé).',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
key: _exportButtonKey,
|
||||||
|
onPressed: _isBackupBusy ? null : _exportBackup,
|
||||||
|
icon: const Icon(Icons.ios_share, size: 18),
|
||||||
|
label: const Text('Exporter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isBackupBusy ? null : _importBackup,
|
||||||
|
icon: const Icon(Icons.file_download_outlined, size: 18),
|
||||||
|
label: const Text('Importer'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_isBackupBusy) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupService _backupService() =>
|
||||||
|
BackupService(repository: context.read<SessionRepository>());
|
||||||
|
|
||||||
|
Future<void> _exportBackup() async {
|
||||||
|
final includeImages = await _askIncludeImages();
|
||||||
|
if (includeImages == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isBackupBusy = true);
|
||||||
|
try {
|
||||||
|
final file = await _backupService().exportToFile(
|
||||||
|
includeImages: includeImages,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// sharePositionOrigin : obligatoire pour l'iPad, ignoré ailleurs.
|
||||||
|
final box = _exportButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
final origin = box != null && box.hasSize
|
||||||
|
? box.localToGlobal(Offset.zero) & box.size
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(file.path, mimeType: 'application/json')],
|
||||||
|
fileNameOverrides: [p.basename(file.path)],
|
||||||
|
subject: 'Sauvegarde IMPACT',
|
||||||
|
text: 'Sauvegarde de mes sessions de tir (${p.basename(file.path)})',
|
||||||
|
sharePositionOrigin: origin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_showMessage('Export impossible : $e', isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isBackupBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Les photos de cibles alourdissent énormément le fichier : on laisse le
|
||||||
|
/// choix entre une sauvegarde légère (données seules) et une sauvegarde
|
||||||
|
/// complète (photos encodées dans le JSON).
|
||||||
|
Future<bool?> _askIncludeImages() {
|
||||||
|
var includeImages = false;
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('Exporter mes données'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Le fichier contiendra toutes tes sessions (cibles, impacts, '
|
||||||
|
'scores), tes statistiques et ton armurerie.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: includeImages,
|
||||||
|
onChanged: (v) => setDialogState(() => includeImages = v),
|
||||||
|
title: const Text('Inclure les photos des cibles'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Sauvegarde complète, mais fichier beaucoup plus lourd.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, includeImages),
|
||||||
|
child: const Text('Exporter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importBackup() async {
|
||||||
|
const jsonGroup = XTypeGroup(
|
||||||
|
label: 'Sauvegarde IMPACT (.json)',
|
||||||
|
extensions: ['json'],
|
||||||
|
// Android renvoie parfois un type générique pour un .json : on accepte
|
||||||
|
// large, le contenu est validé à la lecture de toute façon.
|
||||||
|
mimeTypes: ['application/json', 'text/plain', 'application/octet-stream'],
|
||||||
|
uniformTypeIdentifiers: ['public.json', 'public.text'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final picked = await openFile(acceptedTypeGroups: const [jsonGroup]);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isBackupBusy = true);
|
||||||
|
try {
|
||||||
|
final service = _backupService();
|
||||||
|
final preview = await service.readBackup(File(picked.path));
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final confirmed = await _confirmImport(preview);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final result = await service.applyBackup(preview);
|
||||||
|
await _loadStatistics();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final details = [
|
||||||
|
'${result.sessions} session(s)',
|
||||||
|
'${result.weapons} arme(s)',
|
||||||
|
if (result.maintenance > 0) '${result.maintenance} entretien(s)',
|
||||||
|
if (result.images > 0) '${result.images} photo(s)',
|
||||||
|
].join(' · ');
|
||||||
|
_showMessage(
|
||||||
|
result.errors.isEmpty
|
||||||
|
? 'Import terminé : $details'
|
||||||
|
: 'Import terminé : $details — ${result.errors.length} entrée(s) ignorée(s)',
|
||||||
|
);
|
||||||
|
} on BackupFormatException catch (e) {
|
||||||
|
_showMessage(e.message, isError: true);
|
||||||
|
} catch (e) {
|
||||||
|
_showMessage('Import impossible : $e', isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isBackupBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirmImport(BackupPreview preview) {
|
||||||
|
final date = preview.exportedAt;
|
||||||
|
final dateLabel = date == null
|
||||||
|
? null
|
||||||
|
: '${date.day.toString().padLeft(2, '0')}/'
|
||||||
|
'${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||||
|
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Importer cette sauvegarde ?'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (dateLabel != null) Text('Exportée le $dateLabel'),
|
||||||
|
if (dateLabel != null) const SizedBox(height: 8),
|
||||||
|
Text('• ${preview.sessionCount} session(s)'),
|
||||||
|
Text('• ${preview.targetCount} cible(s), ${preview.shotCount} impact(s)'),
|
||||||
|
Text('• ${preview.weaponCount} arme(s), ${preview.maintenanceCount} entretien(s)'),
|
||||||
|
Text(preview.hasImages
|
||||||
|
? '• Photos des cibles incluses'
|
||||||
|
: '• Sans photos de cibles'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Les données actuelles sont conservées. Une session déjà '
|
||||||
|
'présente est simplement mise à jour.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Importer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessage(String message, {bool isError = false}) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red.shade700 : null,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HeatMapPainter extends CustomPainter {
|
class _HeatMapPainter extends CustomPainter {
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ import 'features/statistics/statistics_screen.dart';
|
|||||||
import 'features/garage/weapon_list_screen.dart';
|
import 'features/garage/weapon_list_screen.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Index des onglets de la barre de navigation principale.
|
||||||
|
const int mainTabHome = 0;
|
||||||
|
const int mainTabHistory = 1;
|
||||||
|
const int mainTabStats = 2;
|
||||||
|
const int mainTabGarage = 3;
|
||||||
|
|
||||||
|
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel
|
||||||
|
/// écran (ex. fin de session -> onglet Stats).
|
||||||
|
final GlobalKey<State<MainNavigationHolder>> mainNavKey =
|
||||||
|
GlobalKey<State<MainNavigationHolder>>();
|
||||||
|
|
||||||
|
/// Ouvre l'onglet [index] de la navigation principale.
|
||||||
|
void openMainTab(int index) {
|
||||||
|
final state = mainNavKey.currentState;
|
||||||
|
if (state is _MainNavigationHolderState) state.selectTab(index);
|
||||||
|
}
|
||||||
|
|
||||||
class MainNavigationHolder extends StatefulWidget {
|
class MainNavigationHolder extends StatefulWidget {
|
||||||
const MainNavigationHolder({super.key});
|
const MainNavigationHolder({super.key});
|
||||||
|
|
||||||
@@ -20,22 +37,27 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
|||||||
// rafraîchissent pas seuls).
|
// rafraîchissent pas seuls).
|
||||||
int _statsTick = 0;
|
int _statsTick = 0;
|
||||||
int _historyTick = 0;
|
int _historyTick = 0;
|
||||||
|
int _homeTick = 0;
|
||||||
|
int _garageTick = 0;
|
||||||
|
|
||||||
void _onItemTapped(int index) {
|
void selectTab(int index) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedIndex = index;
|
_selectedIndex = index;
|
||||||
if (index == 1) _historyTick++;
|
if (index == mainTabHome) _homeTick++;
|
||||||
if (index == 2) _statsTick++;
|
if (index == mainTabHistory) _historyTick++;
|
||||||
|
if (index == mainTabStats) _statsTick++;
|
||||||
|
if (index == mainTabGarage) _garageTick++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screens = [
|
final screens = [
|
||||||
const HomeScreen(),
|
HomeScreen(refreshTick: _homeTick),
|
||||||
HistoryScreen(refreshTick: _historyTick),
|
HistoryScreen(refreshTick: _historyTick),
|
||||||
StatisticsScreen(refreshTick: _statsTick),
|
StatisticsScreen(refreshTick: _statsTick),
|
||||||
const WeaponListScreen(),
|
WeaponListScreen(refreshTick: _garageTick),
|
||||||
];
|
];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
@@ -54,7 +76,7 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
|||||||
),
|
),
|
||||||
child: BottomNavigationBar(
|
child: BottomNavigationBar(
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
onTap: _onItemTapped,
|
onTap: selectTab,
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
backgroundColor: Theme.of(context).cardColor,
|
backgroundColor: Theme.of(context).cardColor,
|
||||||
selectedItemColor: AppTheme.primaryColor,
|
selectedItemColor: AppTheme.primaryColor,
|
||||||
|
|||||||
@@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import Foundation
|
|||||||
|
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_selector
|
||||||
|
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
file_selector_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_android
|
||||||
|
sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2+6"
|
||||||
|
file_selector_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_ios
|
||||||
|
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.3+5"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -225,6 +249,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "2.7.0"
|
||||||
|
file_selector_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_web
|
||||||
|
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
file_selector_windows:
|
file_selector_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -709,6 +741,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.2.0"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.3.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -898,6 +946,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.5"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+4
-4
@@ -70,6 +70,8 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
camera: ^0.12.0+1
|
camera: ^0.12.0+1
|
||||||
|
share_plus: ^13.3.0
|
||||||
|
file_selector: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -93,10 +95,8 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/icons/
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
@@ -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<Session> sessions;
|
||||||
|
final List<Weapon> weapons;
|
||||||
|
final List<MaintenanceEntry> maintenance;
|
||||||
|
final List<List<int>> savedImages = [];
|
||||||
|
|
||||||
|
_FakeRepository({
|
||||||
|
List<Session>? sessions,
|
||||||
|
List<Weapon>? weapons,
|
||||||
|
List<MaintenanceEntry>? maintenance,
|
||||||
|
}) : sessions = sessions ?? [],
|
||||||
|
weapons = weapons ?? [],
|
||||||
|
maintenance = maintenance ?? [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Session>> getAllSessions({int? limit, int? offset}) async =>
|
||||||
|
sessions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Weapon>> getWeapons() async => weapons;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MaintenanceEntry>> getAllMaintenance() async => maintenance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSession(Session session) async {
|
||||||
|
sessions.removeWhere((s) => s.id == session.id);
|
||||||
|
sessions.add(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveWeapon(Weapon weapon) async {
|
||||||
|
weapons.removeWhere((w) => w.id == weapon.id);
|
||||||
|
weapons.add(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
||||||
|
maintenance.removeWhere((e) => e.id == entry.id);
|
||||||
|
maintenance.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> saveImageBytes(List<int> 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<BackupPreview> _roundTrip(
|
||||||
|
Map<String, dynamic> 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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
final analysis = (session['analyses'] as List).single as Map<String, dynamic>;
|
||||||
|
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<BackupFormatException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final otherJson = File('${dir.path}/autre.json');
|
||||||
|
await otherJson.writeAsString('{"hello": "world"}');
|
||||||
|
expect(
|
||||||
|
() => service.readBackup(otherJson),
|
||||||
|
throwsA(isA<BackupFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<BackupFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,10 +8,16 @@
|
|||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
|
share_plus
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user