26 Commits

Author SHA1 Message Date
qguillaume
a102bfd5ef fix: change adresse mail support à déterminer 2026-06-19 15:12:44 +02:00
qguillaume
89b4f433b6 feat: bouton Signaler un bug dans les paramètres (rapport copiable avec infos techniques) 2026-06-19 15:03:15 +02:00
qguillaume
9fa3a6f46d feat: accessoires en armurerie, comparateur de sessions stats, croix de centrage pixel par pixel 2026-06-19 14:40:19 +02:00
qguillaume
8165d3bab3 feat: édition rapide des accessoires créant une nouvelle arme/config pour différencier les stats 2026-06-19 14:09:46 +02:00
qguillaume
ba9975f047 fix: première arme invisible (race init DB) + recentrage des champs sous le clavier dans la modale d'édition d'arme 2026-06-19 13:41:01 +02:00
qguillaume
f7fecf0ef2 Lorsqu'on termine la session et qu'on a l'écran d'accueil > exécuter un refresh automatique pour prendre en compte cette dernière sessions dans les star de l'écran d'accueil. Actuellement il faut refresh manuellement pour que ce soit pris en compte -Dans Armurerie > Mon arme > le nb de session n'est pas remonté Ajouter un date picker pour y inscrire une date de l'entretien et qu'elle apparaisse dans la liste en bas (dans la modale ajouter un entretien) 2026-06-19 13:12:31 +02:00
qguillaume
0564bdbb48 fix: lag lors de la prise de photo 2026-06-17 20:00:14 +02:00
qguillaume
05356aaaea fix: centrage de la cible image pleine ecran respectant la prise origine au niveau des coins verts/orange 2026-06-17 19:42:13 +02:00
qguillaume
c4880ccb68 fix: cadre de centrage au ratio de la photo + bouton calibration Valider 2026-06-16 11:29:08 +02:00
qguillaume
00ae0117c5 chore: supprime le code mort signale par flutter analyze
Retire imports, champs et variables locales non utilises (14 warnings) :
- imports inutilises (app, database_helper, impact_editor, target_calibration, session_provider)
- champs write-only de capture_screen (_cropService, _scanAnimation, _isCameraInitialized, _alignmentStatus) et _movingShotId
- bloc de calcul mort dans crop_screen et variables locales (themeProvider, ny)

_filterType (history_screen) volontairement conserve : c'est une fonctionnalite de filtre inachevee, pas du code mort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:50:25 +02:00
qguillaume
1d8124c8d8 fix: clarifie les libelles de stats et corrige le calcul de l etalement moyen
- score_card: "Pourcentage" -> "Reussite", ajoute "/10" sur la moyenne
- grouping_stats: "Diametre" -> "Etalement", direction du decalage en toutes lettres (Droite, Haut-Droite...) au lieu de D/G/H/B
- statistics: corrige le bug de priorite (... ?? 0 * 100) qui affichait un etalement moyen errone (~0.9% au lieu de ~90%)
- ajoute un bouton d aide reutilisable (MetricInfoButton) expliquant chaque metrique, dont la distinction Precision (distance) / Reussite (score)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:27:10 +02:00
qguillaume
600a6bef30 fix: ratio aspect camera rectangulaire 2026-06-12 11:33:41 +02:00
qguillaume
5d2e612b9c fix: reeduction temps prise photo et enleve confusion photo en cours de traitement 2026-06-12 11:17:16 +02:00
qguillaume
4e26431be5 fix: inclinaison 2 à 3 degrés vert orange 2026-06-11 21:50:06 +02:00
qguillaume
559369c84d fix: recentrage incorrect si zoom 2026-06-11 21:21:35 +02:00
qguillaume
653aa5d5b0 fix: recentrage incorrect si loin du centre d origine 2026-06-11 21:02:47 +02:00
qguillaume
fec33a327a fix: recentrage incorrect si loin du centre d origine 2026-06-11 20:47:47 +02:00
qguillaume
8e64839fc6 fix: capture photo plus robuste 2026-06-11 20:24:57 +02:00
qguillaume
331fda0ffb fix: freeze et deformation lors de la prise de la photo (rotation) 2026-06-11 20:04:25 +02:00
qguillaume
064f5c1fe3 chore: ignorer le dossier de configuration .claude 2026-06-11 19:46:11 +02:00
qguillaume
b4226216eb feat: retrait cercle et encadre + decalage centrage de la cible pris en compte 2026-06-11 19:40:30 +02:00
qguillaume
2107f187b6 feat: adding new plotting screen fixing pan and tap 2026-06-09 00:12:10 +02:00
qguillaume
0c509bfdc3 fix: trying to avoid conflict first with tap and pin on plotting screen in progress yet 2026-06-08 23:43:33 +02:00
qguillaume
71ad670ad8 fix: trying to avoid conflict first with tap and pin on plotting screen 2026-06-08 23:27:44 +02:00
qguillaume
625810234e fix: light zoom at back 2026-06-08 23:09:47 +02:00
qguillaume
9afe3c8508 fix: opacity 80% for impacts + lower quality for pics + removing slowly zoom if you back go back 2026-06-08 22:30:51 +02:00
25 changed files with 2536 additions and 660 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)"
]
}
}

View File

@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter clean:*)",
"Bash(flutter pub get:*)",
"Bash(flutter run:*)",
"Bash(cmake:*)",
"Bash(where:*)",
"Bash(winget search:*)",
"Bash(winget install:*)",
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
"Bash(flutter doctor:*)"
]
}
}

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
.claude/
# IntelliJ related # IntelliJ related
*.iml *.iml

View File

@@ -37,9 +37,6 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart'; import 'main_navigation_holder.dart';
import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget { class BullyApp extends StatelessWidget {

View File

@@ -0,0 +1,76 @@
/// Bouton d'information (ⓘ) qui explique des métriques à l'utilisateur.
///
/// Affiche une petite icône cliquable ; au clic, une boîte de dialogue
/// détaille la signification de chaque statistique de la carte associée.
library;
import 'package:flutter/material.dart';
/// Explication d'une métrique : un libellé et sa description.
class MetricExplanation {
final String label;
final String description;
const MetricExplanation(this.label, this.description);
}
class MetricInfoButton extends StatelessWidget {
final String title;
final List<MetricExplanation> explanations;
const MetricInfoButton({
super.key,
required this.title,
required this.explanations,
});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.info_outline, size: 18, color: Colors.grey[500]),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'À quoi ça correspond ?',
onPressed: () => _showInfo(context),
);
}
void _showInfo(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final e in explanations)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(e.description),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Compris'),
),
],
),
);
}
}

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../models/session.dart'; import '../models/session.dart';
@@ -11,6 +10,12 @@ import '../../core/constants/app_constants.dart';
class DatabaseHelper { class DatabaseHelper {
static DatabaseHelper? _instance; static DatabaseHelper? _instance;
static Database? _database; static Database? _database;
// On met en cache le Future d'initialisation (et non la Database résolue)
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
// toute première écriture peu fiable.
static Future<Database>? _initFuture;
DatabaseHelper._internal(); DatabaseHelper._internal();
@@ -20,7 +25,9 @@ class DatabaseHelper {
} }
Future<Database> get database async { Future<Database> get database async {
_database ??= await _initDatabase(); if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
return _database!; return _database!;
} }
@@ -510,6 +517,16 @@ class DatabaseHelper {
return Sqflite.firstIntValue(result) ?? 0; return Sqflite.firstIntValue(result) ?? 0;
} }
Future<int> getSessionCountForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(id) as count
FROM ${AppConstants.sessionsTable}
WHERE weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async { Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database; final db = await database;
return await db.insert( return await db.insert(
@@ -543,5 +560,6 @@ class DatabaseHelper {
final db = await database; final db = await database;
await db.close(); await db.close();
_database = null; _database = null;
_initFuture = null;
} }
} }

View File

@@ -182,18 +182,23 @@ class SessionRepository {
return await _databaseHelper.getRoundsFiredForWeapon(weaponId); return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
} }
Future<int> getSessionCountForWeapon(String weaponId) async {
return await _databaseHelper.getSessionCountForWeapon(weaponId);
}
Future<void> addMaintenanceEntry({ Future<void> addMaintenanceEntry({
required String weaponId, required String weaponId,
required MaintenanceType type, required MaintenanceType type,
required String description, required String description,
int? roundsSinceLast, int? roundsSinceLast,
DateTime? date,
}) async { }) async {
final entry = MaintenanceEntry( final entry = MaintenanceEntry(
id: _uuid.v4(), id: _uuid.v4(),
weaponId: weaponId, weaponId: weaponId,
type: type, type: type,
description: description, description: description,
date: DateTime.now(), date: date ?? DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast, roundsSinceLastMaintenance: roundsSinceLast,
); );
await _databaseHelper.insertMaintenance(entry); await _databaseHelper.insertMaintenance(entry);

View File

@@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import '../capture/capture_screen.dart'; import '../capture/capture_screen.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
@@ -28,11 +29,7 @@ import 'widgets/grouping_stats.dart';
class AnalysisScreen extends StatelessWidget { class AnalysisScreen extends StatelessWidget {
final String imagePath; final String imagePath;
final String? originalImagePath; // AJOUT : image source avant le crop à 85%
/// Image originale (jamais recadrée), transmise pour pouvoir revenir au
/// centrage sans recadrer un résultat déjà recadré.
final String? originalImagePath;
final TargetType targetType; final TargetType targetType;
final double? initialCenterX; final double? initialCenterX;
final double? initialCenterY; final double? initialCenterY;
@@ -43,7 +40,7 @@ class AnalysisScreen extends StatelessWidget {
const AnalysisScreen({ const AnalysisScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
this.originalImagePath, this.originalImagePath, // AJOUT
required this.targetType, required this.targetType,
this.initialCenterX, this.initialCenterX,
this.initialCenterY, this.initialCenterY,
@@ -81,7 +78,7 @@ class AnalysisScreen extends StatelessWidget {
return p; return p;
}, },
child: _AnalysisScreenContent( child: _AnalysisScreenContent(
originalImagePath: originalImagePath ?? imagePath, originalImagePath: originalImagePath, // AJOUT
cropScale: cropScale, cropScale: cropScale,
cropOffset: cropOffset, cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage cropRotation: cropRotation, // Envoyé à la structure d'affichage
@@ -91,13 +88,13 @@ class AnalysisScreen extends StatelessWidget {
} }
class _AnalysisScreenContent extends StatefulWidget { class _AnalysisScreenContent extends StatefulWidget {
final String originalImagePath; final String? originalImagePath; // AJOUT
final double? cropScale; final double? cropScale;
final Offset? cropOffset; final Offset? cropOffset;
final double? cropRotation; final double? cropRotation;
const _AnalysisScreenContent({ const _AnalysisScreenContent({
required this.originalImagePath, this.originalImagePath, // AJOUT
this.cropScale, this.cropScale,
this.cropOffset, this.cropOffset,
this.cropRotation, this.cropRotation,
@@ -121,7 +118,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TransformationController(); TransformationController();
final GlobalKey _imageKey = GlobalKey(); final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
String? _movingShotId;
@override @override
void initState() { void initState() {
@@ -160,20 +156,56 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
} }
/// Revient au Centrage en repartant de l'image ORIGINALE. /// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
/// On ne restaure que la rotation (jamais le zoom) → pas de cumul. ///
void _goBackToCrop(AnalysisProvider provider) { /// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
Navigator.pushReplacement( /// persiste dans le TransformationController et se réapplique au retour,
context, /// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
/// d'une transformation identité (zoom 1.0).
void _enterCalibration() {
_transformationController.value = Matrix4.identity();
_currentZoomScale = 1.0;
setState(() => _isCalibrating = true);
}
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
///
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
/// 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.
///
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
/// Plotting (lecture seule) ; sinon on repasse en calibration.
Future<void> _openImpactEditor(AnalysisProvider provider) async {
final validated = await Navigator.of(context).push<bool>(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => CropScreen( builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
imagePath: widget.originalImagePath, value: provider,
originalImagePath: widget.originalImagePath, child: const ImpactEditorScreen(),
targetType: provider.targetType!,
initialRotation: provider.cropRotation,
), ),
), ),
); );
if (!mounted) return;
if (validated == true) {
setState(() {
_isCalibrating = false;
_isSelectingReferences = false;
});
} else {
_enterCalibration();
}
}
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
///
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
/// Repartir de l'image déjà croppée à 85% provoquait un rognage cumulatif
/// (0.85 x 0.85 x ...) qui re-zoomait la photo à chaque aller-retour
/// entre la capture/crop et la calibration.
String _backCropImagePath(AnalysisProvider provider) {
return widget.originalImagePath ?? provider.imagePath!;
} }
@override @override
@@ -197,9 +229,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
onPressed: () { onPressed: () {
if (_isCalibrating) { if (_isCalibrating) {
final provider = context.read<AnalysisProvider>(); final provider = context.read<AnalysisProvider>();
_goBackToCrop(provider); Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
// CORRECTION : on repart de l'image SOURCE (non rognée) pour
// éviter le rognage cumulatif à 85% qui re-zoomait à chaque retour.
imagePath: _backCropImagePath(provider),
targetType: provider.targetType!,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
),
),
);
} else { } else {
setState(() => _isCalibrating = true); // Retour Plotting -> Calibration : on réinitialise le zoom.
_enterCalibration();
} }
}, },
), ),
@@ -214,9 +259,16 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
if (_isCalibrating) if (_isCalibrating)
TextButton( TextButton(
onPressed: () => setState(() => _isCalibrating = false), 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( child: const Text(
'TERMINER', 'VALIDER',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -308,7 +360,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
], ],
) )
: _buildZoomableImageWithOverlay(context, provider), : _buildReadOnlyPlotImage(context, provider),
), ),
if (!_isCalibrating) if (!_isCalibrating)
@@ -316,6 +368,27 @@ 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(
@@ -331,7 +404,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Icons.arrow_forward_ios, Icons.arrow_forward_ios,
size: 16, size: 16,
), ),
onTap: () => setState(() => _isCalibrating = true), // Retour vers la calibration : on réinitialise le zoom.
onTap: () => _enterCalibration(),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -484,7 +558,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
); );
} }
Widget _buildZoomableImageWithOverlay( /// Affichage du plotting en LECTURE SEULE.
///
/// L'édition (ajout / déplacement / suppression) se fait désormais
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
/// Le tap sur un impact ouvre simplement ses détails.
Widget _buildReadOnlyPlotImage(
BuildContext context, BuildContext context,
AnalysisProvider provider, AnalysisProvider provider,
) { ) {
@@ -492,126 +572,30 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
maxScale: 10.0, maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(double.infinity), boundaryMargin: const EdgeInsets.all(80),
panEnabled: _movingShotId == null, panEnabled: true,
child: Transform( child: Stack(
transform: Matrix4.identity() children: [
..setTranslationRaw( Image.file(
widget.cropOffset?.dx ?? 0.0, File(provider.imagePath!),
widget.cropOffset?.dy ?? 0.0, key: _imageKey,
0.0, fit: BoxFit.contain,
)
..scale(1.0, 1.0)
..rotateZ((provider.cropRotation) * (math.pi / 180)),
alignment: Alignment.center,
child: GestureDetector(
onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition);
final relX = localOffset.dx / box.size.width;
final relY = localOffset.dy / box.size.height;
if (provider.shots.isEmpty) return;
Shot? closestShot;
double minDistance = double.infinity;
const double clickTolerance = 0.05;
for (final shot in provider.shots) {
final dx = shot.x - relX;
final dy = shot.y - relY;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < clickTolerance) {
minDistance = distance;
closestShot = shot;
}
}
if (closestShot != null) {
_showShotDetails(context, provider, closestShot);
}
},
onLongPressStart: (LongPressStartDetails details) {
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition);
final relX = localOffset.dx / box.size.width;
final relY = localOffset.dy / box.size.height;
if (provider.shots.isEmpty) return;
Shot? closestShot;
double minDistance = double.infinity;
const double dragTolerance = 0.06;
for (final shot in provider.shots) {
final dx = shot.x - relX;
final dy = shot.y - relY;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < dragTolerance) {
minDistance = distance;
closestShot = shot;
}
}
if (closestShot != null) {
setState(() {
_movingShotId = closestShot!.id;
});
}
},
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
if (_movingShotId == null) return;
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final adjustedGlobalPosition =
details.globalPosition + const Offset(-25, -35);
final localOffset = box.globalToLocal(adjustedGlobalPosition);
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
provider.updateShotPosition(_movingShotId!, relX, relY);
},
onLongPressEnd: (_) {
if (_movingShotId != null) {
setState(() {
_movingShotId = null;
});
}
},
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.contain,
),
TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
onAddShot: (relX, relY) => provider.addShot(relX, relY),
),
],
), ),
), Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
),
),
],
), ),
); );
} }
@@ -713,18 +697,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
final path = _backCropImagePath(provider);
final type = provider.targetType!; final type = provider.targetType!;
// Retour au centrage : on repart de l'ORIGINAL (pas de re-crop),
// en conservant uniquement la rotation.
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => CropScreen( builder: (context) => CropScreen(
imagePath: widget.originalImagePath, imagePath: path,
originalImagePath: widget.originalImagePath,
targetType: type, targetType: type,
initialRotation: provider.cropRotation, initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
), ),
), ),
); );

View File

@@ -0,0 +1,283 @@
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
///
/// Cet écran est volontairement minimal : un Scaffold dont le body est
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
/// scroll vertical ou une transformation parente.
///
/// Interactions :
/// - Tap sur zone vide -> ajoute un impact
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
/// - Appui long + glisser -> déplace l'impact
///
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
library;
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/shot.dart';
import 'analysis_provider.dart';
import 'widgets/target_overlay.dart';
class ImpactEditorScreen extends StatefulWidget {
const ImpactEditorScreen({super.key});
@override
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
}
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
final TransformationController _transformationController =
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
}
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
super.dispose();
}
void _onTransformChanged() {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale != _currentZoomScale) {
setState(() => _currentZoomScale = scale);
}
}
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
Offset? _toImageRelative(Offset globalPosition) {
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
return Offset(relX, relY);
}
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
{double tolerance = 0.04}) {
Shot? closest;
double minDistance = double.infinity;
for (final shot in provider.shots) {
final dx = shot.x - rel.dx;
final dy = shot.y - rel.dy;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < tolerance) {
minDistance = distance;
closest = shot;
}
}
return closest;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: Text('Placement des impacts (${provider.shotCount})'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la calibration',
onPressed: () => Navigator.pop(context, false),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'VALIDER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
body: Column(
children: [
// Bandeau d'aide compact
Container(
width: double.infinity,
color: Colors.white10,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text(
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
style: TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
// Zone image plein écran : InteractiveViewer dans un body nu.
Expanded(
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 12.0,
boundaryMargin: const EdgeInsets.all(80),
panEnabled: _movingShotId == null,
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// TAP : éditer si on touche un impact, sinon ajouter.
onTapUp: (details) {
if (_movingShotId != null) return;
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
final hit = _hitTestShot(provider, rel);
if (hit != null) {
_showShotDetails(context, provider, hit);
} else {
provider.addShot(rel.dx, rel.dy);
}
},
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
onLongPressStart: (details) {
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
if (hit != null) {
setState(() => _movingShotId = hit.id);
}
},
onLongPressMoveUpdate: (details) {
if (_movingShotId == null) return;
// Décalage pour que l'impact reste visible au-dessus du doigt.
final adjusted =
details.globalPosition + const Offset(-25, -35);
final rel = _toImageRelative(adjusted);
if (rel == null) return;
provider.updateShotPosition(
_movingShotId!, rel.dx, rel.dy);
},
onLongPressEnd: (_) {
if (_movingShotId != null) {
setState(() => _movingShotId = null);
}
},
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.contain,
),
Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// L'ajout et la sélection sont gérés par le
// GestureDetector parent ci-dessus.
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
),
),
],
),
),
),
),
),
],
),
);
}
void _showShotDetails(
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),
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.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 '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart'; import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget { class GroupingStats extends StatelessWidget {
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const MetricInfoButton(
title: 'Groupement',
explanations: [
MetricExplanation(
'Étalement',
'Distance entre vos deux impacts les plus éloignés, '
'exprimée en % de la largeur de l\'image. Plus c\'est '
'bas, plus le groupement est serré.',
),
MetricExplanation(
'Dispersion',
'Régularité des impacts autour de leur centre commun '
'(écart-type). Plus c\'est bas, plus vos tirs sont '
'réguliers.',
),
MetricExplanation(
'Décalage',
'Direction du centre de votre groupement par rapport au '
'centre de la cible (ex. « Droite » = vos tirs sont '
'globalement décalés vers la droite).',
),
MetricExplanation(
'Étoiles',
'Qualité globale du groupement, de ★ (à améliorer) à '
'★★★★★ (excellent).',
),
],
),
const Spacer(), const Spacer(),
_buildQualityBadge(context), _buildQualityBadge(context),
], ],
), ),
const Divider(), const Divider(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStat( Expanded(
context, child: _buildStat(
'Diametre', context,
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%', 'Étalement',
icon: Icons.straighten, '${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
), ),
_buildStat( Expanded(
context, child: _buildStat(
'Dispersion', context,
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%', 'Dispersion',
icon: Icons.scatter_plot, '${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
), ),
_buildStat( Expanded(
context, child: _buildStat(
'Decalage', context,
offsetDescription, 'Décalage',
icon: Icons.compare_arrows, offsetDescription,
icon: Icons.compare_arrows,
),
), ),
], ],
), ),
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
label, label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) { String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) { if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre'; return 'Centré';
} }
String vertical = ''; String vertical = '';
String horizontal = ''; String horizontal = '';
if (offsetY < -0.02) { if (offsetY < -0.02) {
vertical = 'H'; vertical = 'Haut';
} else if (offsetY > 0.02) { } else if (offsetY > 0.02) {
vertical = 'B'; vertical = 'Bas';
} }
if (offsetX < -0.02) { if (offsetX < -0.02) {
horizontal = 'G'; horizontal = 'Gauche';
} else if (offsetX > 0.02) { } else if (offsetX > 0.02) {
horizontal = 'D'; horizontal = 'Droite';
} }
if (vertical.isNotEmpty && horizontal.isNotEmpty) { if (vertical.isNotEmpty && horizontal.isNotEmpty) {

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.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 '../../../core/widgets/metric_info_button.dart';
import '../../../data/models/target_type.dart'; import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart'; import '../../../services/score_calculator_service.dart';
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(),
MetricInfoButton(
title: 'Score',
explanations: [
MetricExplanation(
'Total',
'Somme des points de tous vos impacts, sur le maximum '
'possible (nombre d\'impacts × $maxScore points).',
),
const MetricExplanation(
'Impacts',
'Nombre de tirs détectés sur la cible.',
),
MetricExplanation(
'Moyenne',
'Points marqués en moyenne par impact, sur $maxScore.',
),
const MetricExplanation(
'Réussite',
'Votre score exprimé en pourcentage du score maximum '
'possible. C\'est une mesure du résultat, pas de la '
'régularité des tirs.',
),
],
),
], ],
), ),
const Divider(), const Divider(),
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
shotCount > 0 shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1) ? (totalScore / shotCount).toStringAsFixed(1)
: '-', : '-',
subtitle: '/ $maxScore',
), ),
if (scoreResult != null) if (scoreResult != null)
_buildScoreStat( _buildScoreStat(
context, context,
'Pourcentage', 'Réussite',
'${scoreResult!.percentage.toStringAsFixed(0)}%', '${scoreResult!.percentage.toStringAsFixed(0)}%',
), ),
], ],

View File

@@ -4,7 +4,6 @@
/// Les anneaux sont répartis proportionnellement. /// Les anneaux sont répartis proportionnellement.
library; library;
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
@@ -83,6 +82,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
_initRingRadii(); _initRingRadii();
} }
/// Fige et propage la calibration courante vers le provider.
///
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
/// correspond exactement au dernier réglage validé, sans dépendre d'un
/// éventuel rebuild intermédiaire.
void commitCalibration() {
_notifyChange();
}
void _initRingRadii({bool forceRecalculate = false}) { void _initRingRadii({bool forceRecalculate = false}) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine // CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) { if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
@@ -328,24 +337,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
), ),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)), _buildSignLabel(''),
Row( const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)), _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
const SizedBox(width: 40), Row(
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)), mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
], ],
), ),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)), const SizedBox(width: 12),
_buildSignLabel('+'),
], ],
), ),
); );
} }
// Symbole purement décoratif affiché de part et d'autre de la croix.
Widget _buildSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) { Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container( return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),

View File

@@ -1,8 +1,9 @@
/// Overlay visuel de la cible. /// Overlay visuel de la cible.
/// ///
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement /// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout /// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants
/// d'impacts et la sélection d'impacts existants. /// (tap sur un impact). L'AJOUT d'un impact est délégué à l'écran parent pour
/// éviter tout conflit de gestes avec le zoom/pan de l'InteractiveViewer.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -48,76 +49,69 @@ class TargetOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( // IMPORTANT : plus de GestureDetector global ici.
builder: (context, constraints) { // L'ancien GestureDetector (onTapUp couvrant toute la surface, en
return GestureDetector( // HitTestBehavior.translucent) volait les pointeurs au pinch de
behavior: HitTestBehavior.translucent, // l'InteractiveViewer parent et rendait le zoom capricieux.
onTapUp: (details) { //
if (onAddShot != null) { // Désormais :
// Utiliser les constraints pour un calcul précis // - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
final relX = details.localPosition.dx / constraints.maxWidth; // - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
final relY = details.localPosition.dy / constraints.maxHeight; // zones de tap localisées (deferToChild) placées sur chaque impact.
onAddShot!(relX, relY); return IgnorePointer(
} ignoring: false,
}, child: CustomPaint(
child: CustomPaint( painter: _TargetOverlayPainter(
painter: _TargetOverlayPainter( shots: shots,
shots: shots, targetCenterX: targetCenterX,
targetCenterX: targetCenterX, targetCenterY: targetCenterY,
targetCenterY: targetCenterY, targetRadius: targetRadius,
targetRadius: targetRadius, targetType: targetType,
targetType: targetType, ringCount: ringCount,
ringCount: ringCount, ringRadii: ringRadii,
ringRadii: ringRadii, groupingCenterX: groupingCenterX,
groupingCenterX: groupingCenterX, groupingCenterY: groupingCenterY,
groupingCenterY: groupingCenterY, groupingDiameter: groupingDiameter,
groupingDiameter: groupingDiameter, referenceImpacts: referenceImpacts,
referenceImpacts: referenceImpacts, zoomScale: zoomScale,
zoomScale: zoomScale, showRings: showRings,
showRings: showRings, ),
), child: LayoutBuilder(
child: Stack( builder: (context, constraints) {
return Stack(
children: shots.map((shot) { children: shots.map((shot) {
final x = shot.x * constraints.maxWidth;
final y = shot.y * constraints.maxHeight;
// Zone de tap qui reste constante à l'écran malgré le zoom.
final tapSize = 30 / zoomScale;
final halfTapSize = tapSize / 2;
return Positioned( return Positioned(
left: 0, left: x - halfTapSize,
top: 0, top: y - halfTapSize,
right: 0, child: GestureDetector(
bottom: 0, // deferToChild : ne capte le toucher QUE sur la zone du
child: LayoutBuilder( // Container (un cercle opaque au hit-test), pas ailleurs.
builder: (context, innerConstraints) { // Le reste de la surface reste donc disponible pour le
final x = shot.x * innerConstraints.maxWidth; // pinch/pan de l'InteractiveViewer.
final y = shot.y * innerConstraints.maxHeight; behavior: HitTestBehavior.deferToChild,
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran) onTap: () => onShotTapped?.call(shot),
final tapSize = 30 / zoomScale; child: Container(
final halfTapSize = tapSize / 2; width: tapSize,
return Stack( height: tapSize,
children: [ decoration: const BoxDecoration(
Positioned( // Opaque pour le hit-test (couleur transparente visuellement
left: x - halfTapSize, // mais non nulle), pour que le tap soit bien capté ici.
top: y - halfTapSize, color: Color(0x01000000),
child: GestureDetector( shape: BoxShape.circle,
behavior: HitTestBehavior.translucent, ),
onTap: () => onShotTapped?.call(shot), ),
child: Container(
width: tapSize,
height: tapSize,
decoration: const BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
),
),
),
),
],
);
},
), ),
); );
}).toList(), }).toList(),
), );
), },
); ),
}, ),
); );
} }
} }
@@ -211,8 +205,8 @@ class _TargetOverlayPainter extends CustomPainter {
final prevMultiplier = i == 0 final prevMultiplier = i == 0
? 0.0 ? 0.0
: (ringRadii != null && ringRadii!.length == ringCount) : (ringRadii != null && ringRadii!.length == ringCount)
? ringRadii![i - 1] ? ringRadii![i - 1]
: i / ringCount; : i / ringCount;
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2; final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
final score = 10 - i; final score = 10 - i;
@@ -292,16 +286,17 @@ class _TargetOverlayPainter extends CustomPainter {
final strokeWidth = 3 / zoomScale; final strokeWidth = 3 / zoomScale;
final fontSize = 10 / zoomScale; final fontSize = 10 / zoomScale;
// Draw outer circle (white outline for visibility) // Draw outer circle (white outline for visibility) — gardé OPAQUE pour
// bien repérer le centre même quand le remplissage est transparent.
final outlinePaint = Paint() final outlinePaint = Paint()
..color = AppTheme.impactOutlineColor ..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = strokeWidth; ..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
// Draw impact marker // Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
final impactPaint = Paint() final impactPaint = Paint()
..color = AppTheme.impactColor ..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint); canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,17 @@ import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart'; import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget { class CropScreen extends StatefulWidget {
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
/// cumulatif.
final String imagePath; final String imagePath;
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
/// considéré comme l'original (premier passage depuis la caméra/galerie).
final String? originalImagePath;
final TargetType targetType; final TargetType targetType;
final double? initialScale;
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement final Offset? initialOffset;
/// PAS restauré : on repart toujours de l'image entière.
final double? initialRotation;
const CropScreen({ const CropScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
this.originalImagePath,
required this.targetType, required this.targetType,
this.initialRotation, this.initialScale,
this.initialOffset,
}); });
@override @override
@@ -56,20 +45,14 @@ class _CropScreenState extends State<CropScreen> {
late Size _viewportSize; late Size _viewportSize;
late double _cropSize; late double _cropSize;
/// L'image effectivement travaillée par cet écran : toujours l'originale.
String get _workingImagePath =>
widget.originalImagePath ?? widget.imagePath;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// On restaure uniquement la rotation (pas le zoom).
_rotation = widget.initialRotation ?? 0.0;
_loadImageInfo(); _loadImageInfo();
} }
Future<void> _loadImageInfo() async { Future<void> _loadImageInfo() async {
final file = File(_workingImagePath); final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes()); final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -118,24 +101,9 @@ class _CropScreenState extends State<CropScreen> {
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column( : Column(
children: [ children: [
// Zone interactive de crop // TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
// TEXTE D'AIDE AJUSTÉ
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Column( child: Column(
children: [ children: [
Row( Row(
@@ -143,9 +111,11 @@ class _CropScreenState extends State<CropScreen> {
children: [ children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20), const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Flexible(
'Alignez et pivotez la cible sur la croix', child: Text(
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500), 'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
), ),
], ],
), ),
@@ -158,6 +128,36 @@ class _CropScreenState extends State<CropScreen> {
), ),
), ),
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
// carrée, donc la cible n'est pas déformée.
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
),
),
),
const SizedBox(height: 8),
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
_buildDirectionalPad(),
const SizedBox(height: 16), const SizedBox(height: 16),
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15° // JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
@@ -255,24 +255,11 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) { builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight); _viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert ! // La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0; // visée = toute la zone visible → aucun bord noir autour du cadre.
final viewportAspect = _viewportSize.width / _viewportSize.height; _cropSize = math.min(_viewportSize.width, _viewportSize.height);
double displayWidth, displayHeight; if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
// au lieu des 85% précédents qui rognaient inutilement les bords.
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition(); _initializeImagePosition();
} }
@@ -291,8 +278,8 @@ class _CropScreenState extends State<CropScreen> {
..rotateZ(_rotation * (math.pi / 180)), ..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
File(_workingImagePath), File(widget.imagePath),
fit: BoxFit.contain, fit: BoxFit.cover,
width: _viewportSize.width, width: _viewportSize.width,
height: _viewportSize.height, height: _viewportSize.height,
), ),
@@ -363,23 +350,71 @@ class _CropScreenState extends State<CropScreen> {
void _initializeImagePosition() { void _initializeImagePosition() {
if (_imageSize == null) return; if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height; // 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
final viewportAspect = _viewportSize.width / _viewportSize.height; _scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain // 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
double displayWidth, displayHeight; if (widget.initialOffset != null) {
if (imageAspect > viewportAspect) { _offset = widget.initialOffset!;
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else { } else {
displayHeight = _viewportSize.height; _offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
displayWidth = _viewportSize.height * imageAspect;
} }
}
// On repart TOUJOURS de l'image entière, centrée, sans zoom : // Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours. // Les symboles « » et « + » de part et d'autre sont purement décoratifs.
_scale = 1.0; Widget _buildDirectionalPad() {
_offset = Offset.zero; return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildCropSignLabel(''),
const SizedBox(width: 10),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
const SizedBox(width: 28),
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
],
),
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
],
),
const SizedBox(width: 10),
_buildCropSignLabel('+'),
],
);
}
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 22),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
);
}
Widget _buildCropSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
);
}
void _nudge(double dx, double dy) {
setState(() {
_offset = _offset + Offset(dx, dy);
});
} }
void _onScaleStart(ScaleStartDetails details) { void _onScaleStart(ScaleStartDetails details) {
@@ -399,24 +434,26 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async { Future<void> _onCropConfirm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer // Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants // utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
final savedScale = _scale; // le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final savedOffset = _offset; final double displayPerSourcePx = math.max(
_scale = 1.0; _viewportSize.width / _imageSize!.width,
_offset = Offset.zero; _viewportSize.height / _imageSize!.height,
);
final cropRect = _calculateCropRect(); // Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// On restaure pour l'affichage (au cas où on revient en arrière) // sont remplis en noir → la position choisie est respectée à l'identique.
_scale = savedScale; final croppedImagePath = await _cropService.cropViewport(
_offset = savedOffset; sourcePath: widget.imagePath,
offsetDx: _offset.dx,
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un offsetDy: _offset.dy,
// résultat déjà recadré. displayPerSourcePx: displayPerSourcePx,
final croppedImagePath = await _cropService.cropToSquare( cropSizeDisplay: _cropSize,
_workingImagePath, // Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
cropRect, // il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation, rotationDegrees: _rotation,
); );
@@ -430,13 +467,16 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: croppedImagePath, imagePath: croppedImagePath,
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir // AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// revenir au centrage sans jamais re-recadrer le résultat. // Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
originalImagePath: _workingImagePath, // provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
cropRotation: _rotation, originalImagePath: widget.imagePath,
targetType: widget.targetType, targetType: widget.targetType,
initialCenterX: targetCenterX, initialCenterX: targetCenterX,
initialCenterY: targetCenterY, initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
), ),
), ),
); );
@@ -450,43 +490,4 @@ class _CropScreenState extends State<CropScreen> {
} }
} }
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropWidth = _cropSize / scaledWidth;
final relCropHeight = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropWidth.clamp(0.0, 1.0),
height: relCropHeight.clamp(0.0, 1.0),
);
}
} }

View File

@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon; late Weapon _weapon;
List<MaintenanceEntry> _maintenance = []; List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0; int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id); final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id); final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
if (mounted) { if (mounted) {
setState(() { setState(() {
_maintenance = history; _maintenance = history;
_totalRounds = rounds; _totalRounds = rounds;
_sessionCount = sessions;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
Expanded( Expanded(
child: _buildStatCard( child: _buildStatCard(
'Sessions', 'Sessions',
'N/A', _sessionCount.toString(),
Icons.history, Icons.history,
Colors.orange, Colors.orange,
), ),
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: () => _showEditAccessoriesDialog(context),
),
],
),
const Divider(), const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'), _buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'), _buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
@@ -232,6 +252,33 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
} }
} }
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog(BuildContext context) async { void _showEditWeaponDialog(BuildContext context) async {
final nameController = TextEditingController(text: _weapon.name); final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber); final caliberController = TextEditingController(text: _weapon.caliber);
@@ -253,41 +300,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( _autoScrollOnFocus(TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'), decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: customNameController, controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'), decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
), )),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
value: selectedType, value: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
TextField( _autoScrollOnFocus(TextField(
controller: caliberController, controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'), decoration: const InputDecoration(labelText: 'Calibre'),
), )),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)), const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField( _autoScrollOnFocus(TextField(
controller: opticController, controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'), decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: silencerController, controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'), decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: triggerController, controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'), decoration: const InputDecoration(labelText: 'Détente / Trigger'),
), )),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)), const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row( _autoScrollOnFocus(Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
@@ -305,12 +352,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
), ),
), ),
], ],
), )),
TextField( _autoScrollOnFocus(TextField(
controller: notesController, controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'), decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2, maxLines: 2,
), )),
], ],
), ),
), ),
@@ -344,9 +391,116 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
} }
} }
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog(BuildContext context) async {
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
}
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final repository = context.read<SessionRepository>();
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog(BuildContext context) async { void _showAddMaintenanceDialog(BuildContext context) async {
final descController = TextEditingController(); final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning; MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
@@ -367,6 +521,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'), decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date',
suffixIcon: Icon(Icons.calendar_today, size: 18),
),
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
),
),
], ],
), ),
actions: [ actions: [
@@ -384,6 +559,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
type: selectedType, type: selectedType,
description: descController.text, description: descController.text,
roundsSinceLast: _totalRounds, roundsSinceLast: _totalRounds,
date: selectedDate,
); );
_loadData(); _loadData();
} }

View File

@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
child: ListTile( child: InkWell(
leading: CircleAvatar( borderRadius: BorderRadius.circular(12),
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
@@ -104,12 +89,96 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
_loadWeapons(); // Reload in case it was edited or maintenance was added _loadWeapons(); // Reload in case it was edited or maintenance was added
}, },
onLongPress: () => _confirmDelete(context, weapon), onLongPress: () => _confirmDelete(context, weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
), ),
); );
}, },
); );
} }
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog(BuildContext context) async { void _showAddWeaponDialog(BuildContext context) async {
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();

View File

@@ -25,12 +25,43 @@ class _HomeScreenState extends State<HomeScreen> {
List<Session> _recentSessions = []; List<Session> _recentSessions = [];
bool _isLoading = true; bool _isLoading = true;
SessionProvider? _sessionProvider;
bool _wasSessionActive = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadStats(); _loadStats();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<SessionProvider>();
if (provider != _sessionProvider) {
_sessionProvider?.removeListener(_onSessionChanged);
_sessionProvider = provider;
_sessionProvider!.addListener(_onSessionChanged);
_wasSessionActive = provider.isSessionActive;
}
}
void _onSessionChanged() {
final isActive = _sessionProvider?.isSessionActive ?? false;
// Quand une session vient de se terminer, on rafraîchit les stats
// automatiquement pour prendre en compte la dernière session.
if (_wasSessionActive && !isActive) {
_loadStats();
}
_wasSessionActive = isActive;
}
@override
void dispose() {
_sessionProvider?.removeListener(_onSessionChanged);
super.dispose();
}
Future<void> _loadStats() async { Future<void> _loadStats() async {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics(); final stats = await repository.getStatistics();

View File

@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart'; import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier { class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate; DateTime? _sessionDate;

View File

@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showThemeDialog() { void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -247,6 +245,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() {
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
'Infos techniques jointes : version $appVersion$platform',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
..writeln('--- Rapport de bug ---')
..writeln('Version : $appVersion')
..writeln('Plateforme : $platform')
..writeln('Date : ${DateTime.now().toIso8601String()}')
..writeln('')
..writeln('Description :')
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
Clipboard.setData(ClipboardData(text: report.toString()));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 5),
),
);
},
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: '1.0.0', subtitle: '1.0.0',
onTap: () {}, onTap: () {},
), ),
_buildSettingsTile(
context: context,
icon: Icons.bug_report_outlined,
title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes',
onTap: _showReportBugDialog,
),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.privacy_tip_outlined, icon: Icons.privacy_tip_outlined,

View File

@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
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 '../../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/statistics_service.dart'; import '../../services/statistics_service.dart';
@@ -29,6 +30,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> _availableWeapons = ['Toutes']; List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes']; List<String> _availableDistances = ['Toutes'];
// --- Comparateur de sessions ---
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
// un switch permet d'alterner l'affichage des stats entre la session A et B.
Session? _compareA;
Session? _compareB;
bool _showingB = false; // false = on affiche A, true = on affiche B
bool get _compareMode => _compareA != null && _compareB != null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -79,6 +88,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
void _calculateStats() { void _calculateStats() {
// Mode comparaison : on calcule les stats sur la seule session active
// (A ou B selon le switch), sans filtre de période.
if (_compareMode) {
final active = _showingB ? _compareB! : _compareA!;
_statistics = _statisticsService.calculateStatistics(
[active],
period: StatsPeriod.all,
);
return;
}
// Filtrer les sessions avant calcul // Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) { final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon; final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
@@ -92,6 +112,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
String _sessionLabel(Session s) {
final d = s.createdAt;
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
return '${s.weapon}$date${s.totalScore} pts';
}
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
Future<void> _openCompareDialog() async {
if (_allSessions.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
);
return;
}
Session? a = _compareA ?? _allSessions[0];
Session? b = _compareB ?? _allSessions[1];
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Comparer 2 sessions'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<Session>(
initialValue: a,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session A'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => a = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<Session>(
initialValue: b,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session B'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => b = v),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
],
),
),
);
if (confirmed == true && a != null && b != null) {
setState(() {
_compareA = a;
_compareB = b;
_showingB = false;
_calculateStats();
});
}
}
void _exitCompareMode() {
setState(() {
_compareA = null;
_compareB = null;
_showingB = false;
_calculateStats();
});
}
List<double> _getScoreHistory() { List<double> _getScoreHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0]; if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10 // Sort sessions by date and take last 10
@@ -162,6 +256,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
// 1bis. COMPARATEUR DE SESSIONS
_buildComparator(),
const SizedBox(height: 20), const SizedBox(height: 20),
// 2. DONNÉES RAPIDES (Tirs et Sessions) // 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -191,18 +289,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
'Score', 'Score',
'${_statistics?.totalScore ?? 0}', '${_statistics?.totalScore ?? 0}',
_getScoreHistory(), _getScoreHistory(),
explanations: const [
MetricExplanation(
'Score',
'Total des points marqués sur la période sélectionnée.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Précision', 'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%', '${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
_getPrecisionHistory(), _getPrecisionHistory(),
explanations: const [
MetricExplanation(
'Précision',
'Proximité moyenne de vos impacts par rapport au centre '
'de la cible (calculée sur les distances).',
),
MetricExplanation(
'À ne pas confondre',
'C\'est différent de la « Réussite » affichée dans une '
'session, qui se base sur les points marqués. Les deux '
'mesurent des choses distinctes, leurs pourcentages '
'ne sont donc pas identiques.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Groupement moyen', 'Étalement moyen',
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%', '${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history _getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
explanations: const [
MetricExplanation(
'Étalement moyen',
'Étalement moyen de vos groupements : distance entre les '
'impacts les plus éloignés, en % de la largeur de '
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
),
],
), ),
const SizedBox(height: 25), const SizedBox(height: 25),
@@ -228,6 +354,94 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
Widget _buildComparator() {
final theme = Theme.of(context);
if (!_compareMode) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCompareDialog,
icon: const Icon(Icons.compare_arrows),
label: const Text('Comparer 2 sessions'),
),
);
}
final activeColor = const Color(0xFF1A73E8);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
const Spacer(),
IconButton(
tooltip: 'Quitter la comparaison',
icon: const Icon(Icons.close, size: 18),
onPressed: _exitCompareMode,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Switch pour alterner entre les 2 sessions.
Row(
children: [
Expanded(
child: Text(
'A · ${_sessionLabel(_compareA!)}',
style: TextStyle(
fontSize: 12,
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: _showingB,
activeThumbColor: activeColor,
onChanged: (v) => setState(() {
_showingB = v;
_calculateStats();
}),
),
Expanded(
child: Text(
'B · ${_sessionLabel(_compareB!)}',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
'Affichage : session ${_showingB ? 'B' : 'A'}',
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
),
],
),
);
}
Widget _buildDropdown( Widget _buildDropdown(
String label, String label,
String value, String value,
@@ -302,8 +516,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildChartSection( Widget _buildChartSection(
String title, String title,
String value, String value,
List<double> dataPoints, List<double> dataPoints, {
) { List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -314,9 +529,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, children: [
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
if (explanations != null) ...[
const Spacer(),
MetricInfoButton(title: title, explanations: explanations),
],
],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(

View File

@@ -1,54 +0,0 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}

View File

@@ -55,37 +55,7 @@ void _cropIsolateEntry(_CropParams params) {
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}'); throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
} }
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
// de plusieurs dizaines de mégapixels.
//
// On réduit donc l'image source à une "taille de travail" juste suffisante
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
// valables quelle que soit l'échelle.
final int workMax = (params.outputSize / 0.7).round();
final int longestSide = math.max(originalImage.width, originalImage.height);
if (longestSide > workMax) {
if (originalImage.width >= originalImage.height) {
originalImage = img.copyResize(
originalImage,
width: workMax,
interpolation: img.Interpolation.linear,
);
} else {
originalImage = img.copyResize(
originalImage,
height: workMax,
interpolation: img.Interpolation.linear,
);
}
}
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse // Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
if (params.rotationDegrees != 0.0) { if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate( originalImage = img.copyRotate(
originalImage, originalImage,
@@ -128,11 +98,152 @@ void _cropIsolateEntry(_CropParams params) {
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85)); outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
} }
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
class _ViewportCropParams {
final String sourcePath;
final double offsetDx;
final double offsetDy;
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
final double rotationDegrees;
final int outputSize;
final String outputPath;
_ViewportCropParams({
required this.sourcePath,
required this.offsetDx,
required this.offsetDy,
required this.displayPerSourcePx,
required this.cropSizeDisplay,
required this.zoomScale,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
// l'a placée, même collée à un bord (aucun recentrage forcé).
void _viewportCropIsolateEntry(_ViewportCropParams p) {
final bytes = File(p.sourcePath).readAsBytesSync();
final img.Image? src = img.decodeImage(bytes);
if (src == null) {
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
}
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
img.Image rotated = src;
if (p.rotationDegrees != 0.0) {
rotated = img.copyRotate(
src,
angle: p.rotationDegrees,
interpolation: img.Interpolation.linear,
);
}
final double f = p.displayPerSourcePx;
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
// zoom) → le champ de vision capturé est toujours celui de l'image non
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
final int side = math.max(1, (p.cropSizeDisplay / f).round());
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
// ICI (uniquement pour le pointage) afin que la croix vise le même point
// quel que soit le niveau de zoom.
final double fz = f * p.zoomScale;
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
final int srcX = (cropCenterX - side / 2).round();
final int srcY = (cropCenterY - side / 2).round();
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
final out = img.Image(width: side, height: side, numChannels: 3);
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
// déborde reste noir (padding) → aucun recentrage forcé.
final int vx0 = math.max(0, srcX);
final int vy0 = math.max(0, srcY);
final int vx1 = math.min(rotated.width, srcX + side);
final int vy1 = math.min(rotated.height, srcY + side);
final int vw = vx1 - vx0;
final int vh = vy1 - vy0;
if (vw > 0 && vh > 0) {
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
// (sinon compositeImage étire la source pour remplir la destination).
img.compositeImage(
out,
region,
dstX: vx0 - srcX,
dstY: vy0 - srcY,
dstW: vw,
dstH: vh,
);
}
img.Image result = out;
if (side != p.outputSize) {
result = img.copyResize(
out,
width: p.outputSize,
height: p.outputSize,
interpolation: img.Interpolation.linear,
);
}
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
}
class ImageCropService { class ImageCropService {
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
static const int maxOutputSize = 1024; static const int maxOutputSize = 1024;
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
///
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
Future<String> cropViewport({
required String sourcePath,
required double offsetDx,
required double offsetDy,
required double displayPerSourcePx,
required double cropSizeDisplay,
double zoomScale = 1.0,
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final params = _ViewportCropParams(
sourcePath: sourcePath,
offsetDx: offsetDx,
offsetDy: offsetDy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: cropSizeDisplay,
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// Traitement lourd dans un Isolate → thread UI fluide.
await Isolate.run(() => _viewportCropIsolateEntry(params));
return outputPath;
}
Future<String> cropToSquare( Future<String> cropToSquare(
String sourcePath, String sourcePath,
CropRect cropRect, { CropRect cropRect, {

View File

@@ -119,7 +119,6 @@ class ParallelismService {
// Normalisation par la magnitude réelle (indépendant de g exact) // Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude; final double nx = gx / magnitude;
final double ny = gy / magnitude;
final double nz = gz / magnitude; final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe. // Pitch et Roll mesurent l'inclinaison autour de chaque axe.