16 Commits

Author SHA1 Message Date
qguillaume
3b2172e3ec feat: integration mlkit Document Scanner pour la prise de photo
Remplace la camera maison par le scanner de documents Google ML Kit
qui detecte les bords de la cible et la redresse de face automatiquement
(mode base = sans filtre couleur, pour conserver les couleurs reelles).

- nouveau service DocumentScannerService (scanTarget)
- capture_screen branche sur le scanner mlkit, reglages galerie 1280/q75 conserves
- AndroidManifest: meta-data com.google.mlkit.vision.DEPENDENCIES=docscanner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:20:04 +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
12 changed files with 819 additions and 1072 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/
.swiftpm/
migrate_working_dir/
.claude/
# IntelliJ related
*.iml

View File

@@ -37,6 +37,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- ML Kit Document Scanner : télécharge le module de scan de la cible -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
import '../crop/crop_screen.dart';
import '../capture/capture_screen.dart';
import 'widgets/target_overlay.dart';
@@ -28,6 +29,7 @@ import 'widgets/grouping_stats.dart';
class AnalysisScreen extends StatelessWidget {
final String imagePath;
final String? originalImagePath; // AJOUT : image source avant le crop à 85%
final TargetType targetType;
final double? initialCenterX;
final double? initialCenterY;
@@ -38,6 +40,7 @@ class AnalysisScreen extends StatelessWidget {
const AnalysisScreen({
super.key,
required this.imagePath,
this.originalImagePath, // AJOUT
required this.targetType,
this.initialCenterX,
this.initialCenterY,
@@ -75,6 +78,7 @@ class AnalysisScreen extends StatelessWidget {
return p;
},
child: _AnalysisScreenContent(
originalImagePath: originalImagePath, // AJOUT
cropScale: cropScale,
cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage
@@ -84,11 +88,13 @@ class AnalysisScreen extends StatelessWidget {
}
class _AnalysisScreenContent extends StatefulWidget {
final String? originalImagePath; // AJOUT
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation;
const _AnalysisScreenContent({
this.originalImagePath, // AJOUT
this.cropScale,
this.cropOffset,
this.cropRotation,
@@ -151,6 +157,58 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
}
}
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
///
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
/// persiste dans le TransformationController et se réapplique au retour,
/// 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(
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
value: provider,
child: const ImpactEditorScreen(),
),
),
);
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
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
@@ -176,7 +234,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: provider.imagePath!,
// 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,
@@ -184,7 +244,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
);
} else {
setState(() => _isCalibrating = true);
// Retour Plotting -> Calibration : on réinitialise le zoom.
_enterCalibration();
}
},
),
@@ -199,7 +260,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
if (_isCalibrating)
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(
'TERMINER',
style: TextStyle(
@@ -293,7 +361,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
],
)
: _buildZoomableImageWithOverlay(context, provider),
: _buildReadOnlyPlotImage(context, provider),
),
if (!_isCalibrating)
@@ -301,6 +369,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
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(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile(
@@ -316,7 +405,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Icons.arrow_forward_ios,
size: 16,
),
onTap: () => setState(() => _isCalibrating = true),
// Retour vers la calibration : on réinitialise le zoom.
onTap: () => _enterCalibration(),
),
),
const SizedBox(height: 12),
@@ -469,7 +559,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,
AnalysisProvider provider,
) {
@@ -477,104 +573,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(double.infinity),
panEnabled: _movingShotId == null,
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(
widget.cropOffset?.dx ?? 0.0,
widget.cropOffset?.dy ?? 0.0,
0.0,
)
..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;
});
}
},
boundaryMargin: const EdgeInsets.all(80),
panEnabled: true,
child: Stack(
children: [
Image.file(
@@ -582,7 +582,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
key: _imageKey,
fit: BoxFit.contain,
),
TargetOverlay(
Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
@@ -590,14 +591,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
onAddShot: (relX, relY) => provider.addShot(relX, relY),
),
),
],
),
),
),
);
}
@@ -698,7 +698,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TextButton(
onPressed: () {
Navigator.pop(context);
final path = provider.imagePath!;
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
final path = _backCropImagePath(provider);
final type = provider.targetType!;
Navigator.pushReplacement(

View File

@@ -0,0 +1,284 @@
/// É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 '../../core/theme/app_theme.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

@@ -83,6 +83,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
_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}) {
// 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) {

View File

@@ -1,8 +1,9 @@
/// Overlay visuel de la cible.
///
/// 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
/// d'impacts et la sélection d'impacts existants.
/// et les impacts de référence. Gère uniquement 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;
import 'package:flutter/material.dart';
@@ -48,18 +49,17 @@ class TargetOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
if (onAddShot != null) {
// Utiliser les constraints pour un calcul précis
final relX = details.localPosition.dx / constraints.maxWidth;
final relY = details.localPosition.dy / constraints.maxHeight;
onAddShot!(relX, relY);
}
},
// IMPORTANT : plus de GestureDetector global ici.
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
// HitTestBehavior.translucent) volait les pointeurs au pinch de
// l'InteractiveViewer parent et rendait le zoom capricieux.
//
// Désormais :
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
// zones de tap localisées (deferToChild) placées sur chaque impact.
return IgnorePointer(
ignoring: false,
child: CustomPaint(
painter: _TargetOverlayPainter(
shots: shots,
@@ -76,48 +76,42 @@ class TargetOverlay extends StatelessWidget {
zoomScale: zoomScale,
showRings: showRings,
),
child: Stack(
children: shots.map((shot) {
return Positioned(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, innerConstraints) {
final x = shot.x * innerConstraints.maxWidth;
final y = shot.y * innerConstraints.maxHeight;
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
builder: (context, constraints) {
return Stack(
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 Stack(
children: [
Positioned(
return Positioned(
left: x - halfTapSize,
top: y - halfTapSize,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
// deferToChild : ne capte le toucher QUE sur la zone du
// Container (un cercle opaque au hit-test), pas ailleurs.
// Le reste de la surface reste donc disponible pour le
// pinch/pan de l'InteractiveViewer.
behavior: HitTestBehavior.deferToChild,
onTap: () => onShotTapped?.call(shot),
child: Container(
width: tapSize,
height: tapSize,
decoration: const BoxDecoration(
color: Colors.transparent,
// Opaque pour le hit-test (couleur transparente visuellement
// mais non nulle), pour que le tap soit bien capté ici.
color: Color(0x01000000),
shape: BoxShape.circle,
),
),
),
),
],
);
},
),
);
}).toList(),
),
),
);
},
),
),
);
}
}
@@ -292,16 +286,17 @@ class _TargetOverlayPainter extends CustomPainter {
final strokeWidth = 3 / 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()
..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
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()
..color = AppTheme.impactColor
..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);

View File

@@ -1,11 +1,8 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:camera/camera.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
@@ -14,12 +11,7 @@ import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart';
import '../session/session_provider.dart';
import 'widgets/image_source_button.dart';
import '../../services/image_crop_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/parallelism_service.dart'; // NOUVEAU
import '../../services/target_rectify_service.dart'; // NOUVEAU : redressement
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@@ -28,307 +20,102 @@ class CaptureScreen extends StatefulWidget {
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric;
final OpenCVTargetService _opencvService = OpenCVTargetService();
// NOUVEAU : Service IMU de parallélisme
final ParallelismService _parallelismService = ParallelismService(
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
);
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
final DocumentScannerService _docScanner = DocumentScannerService();
String? _selectedImagePath;
bool _isLoading = false;
// Caméra live
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
bool _showLiveCamera = false;
// Animation pulsation du viseur
late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation;
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
bool? _alignmentStatus;
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
Timer? _detectionTimer;
bool _isAnalyzingFrame = false;
// NOUVEAU : Données IMU en temps réel
ParallelismData? _parallelismData;
StreamSubscription<ParallelismData>? _parallelismSubscription;
// Service de redressement de cible (warp perspective)
final TargetRectifyService _rectifyService = TargetRectifyService();
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
void initState() {
super.initState();
_scanAnimationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.document_scanner,
label: 'Scanner',
onPressed: _isLoading ? null : _scanWithDocumentScanner,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
@override
void dispose() {
_cameraController?.dispose();
_scanAnimationController.dispose();
_detectionTimer?.cancel();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
super.dispose();
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Démarre l'écoute IMU et met à jour l'UI en temps réel
// Déclic → lance le scanner de documents Google ML Kit qui redresse
// automatiquement la cible (feuille rectangulaire) de face.
// ─────────────────────────────────────────────────────────────────────────
void _startParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismService.start();
_parallelismSubscription = _parallelismService.stream.listen((data) {
if (mounted && _showLiveCamera) {
setState(() {
_parallelismData = data;
});
}
});
}
void _stopParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismSubscription = null;
_parallelismService.stop();
_parallelismData = null;
}
// ─────────────────────────────────────────────────────────────────────────
// Calcule la couleur et le message depuis l'IMU + la détection de cible
// ─────────────────────────────────────────────────────────────────────────
/// La cible circulaire est-elle détectée et raisonnablement cadrée ?
bool get _targetReady {
final t = _targetResult;
if (t == null || !t.success) return false;
final bool centered =
t.centerX > 0.15 && t.centerX < 0.85 && t.centerY > 0.15 && t.centerY < 0.85;
final bool bigEnough = t.radius > 0.12;
return centered && bigEnough;
}
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
/// (La détection de cible sert seulement à dessiner le cercle, elle ne
/// bloque jamais le passage au vert.)
Color get _frameColor {
final bool imuAligned =
_parallelismData == null || _parallelismData!.isAligned;
return imuAligned ? const Color(0xFF00FF00) : Colors.orange;
}
/// Message d'aide selon le parallélisme IMU.
String get _alignmentMessage {
if (_parallelismData == null ||
_parallelismData!.status == ParallelismStatus.unknown) {
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
}
// Aligné → message de validation (avec bonus si la cible est détectée)
if (_parallelismData!.isAligned) {
return _targetReady
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
}
// Mal aligné → message directif selon l'axe le plus dévié
final double pitch = _parallelismData!.pitchDegrees;
final double roll = _parallelismData!.rollDegrees;
if (pitch.abs() >= roll.abs()) {
return pitch > 0
? 'INCLINEZ VERS L\'AVANT (${pitch.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS L\'ARRIÈRE (${(-pitch).toStringAsFixed(1)}°)';
} else {
return roll > 0
? 'INCLINEZ VERS LA GAUCHE (${roll.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS LA DROITE (${(-roll).toStringAsFixed(1)}°)';
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialisation de la caméra (inchangée, sauf ajout IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _initLiveCamera() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Accès à l\'appareil photo requis')),
);
}
return;
}
Future<void> _scanWithDocumentScanner() async {
setState(() => _isLoading = true);
try {
if (_cameraController != null) {
await _cameraController!.dispose();
_cameraController = null;
_isCameraInitialized = false;
}
final result = await _docScanner.scanTarget();
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.max,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
await _cameraController!.initialize();
setState(() {
_isCameraInitialized = true;
_showLiveCamera = true;
_alignmentStatus = null;
_targetResult = null; // reset détection cible
_parallelismData = null; // NOUVEAU : reset IMU
});
_startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
}
} catch (e) {
debugPrint('Erreur caméra: $e');
} finally {
setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Détection OpenCV périodique (inchangée)
// ─────────────────────────────────────────────────────────────────────────
void _startAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
DateTime? _lastAnalysis;
_cameraController!.startImageStream((CameraImage cameraImage) async {
if (_isAnalyzingFrame) return;
final now = DateTime.now();
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
if (_lastAnalysis != null &&
now.difference(_lastAnalysis!).inMilliseconds < 1000) return;
_lastAnalysis = now;
_isAnalyzingFrame = true;
try {
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/frame_analysis.jpg';
final img.Image? converted = _convertCameraImage(cameraImage);
if (converted == null) {
_isAnalyzingFrame = false;
if (!result.success) {
// L'utilisateur a annulé le scan : on ne fait rien de plus.
if (mounted) setState(() => _isLoading = false);
return;
}
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
final result = await _opencvService.detectTarget(tempPath);
try {
await File(tempPath).delete();
} catch (_) {}
if (mounted && _showLiveCamera) {
setState(() {
// On garde le résultat complet pour dessiner le cercle.
_targetResult = result.success ? result : null;
if (!result.success) {
_alignmentStatus = null;
} else {
final bool isCentered =
result.centerX > 0.25 && result.centerX < 0.75 &&
result.centerY > 0.25 && result.centerY < 0.75;
final bool isSizedCorrectly = result.radius > 0.15;
_alignmentStatus = isCentered && isSizedCorrectly;
}
_selectedImagePath = result.imagePath;
});
}
_analyzeImage();
} catch (e) {
debugPrint('Erreur analyse frame: $e');
debugPrint('Erreur scanner document: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le scan a échoué. Réessayez.'),
),
);
}
} finally {
_isAnalyzingFrame = false;
}
});
}
void _stopAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
try {
if (_cameraController != null &&
_cameraController!.value.isStreamingImages) {
_cameraController!.stopImageStream();
}
} catch (_) {}
_alignmentStatus = null;
_targetResult = null;
}
img.Image? _convertCameraImage(CameraImage cameraImage) {
try {
final int width = cameraImage.width;
final int height = cameraImage.height;
final int targetW = width ~/ 2;
final int targetH = height ~/ 2;
final yPlane = cameraImage.planes[0];
final uPlane = cameraImage.planes[1];
final vPlane = cameraImage.planes[2];
final image = img.Image(width: targetW, height: targetH);
for (int y = 0; y < targetH; y++) {
for (int x = 0; x < targetW; x++) {
final int srcX = x * 2;
final int srcY = y * 2;
final int yIndex = srcY * yPlane.bytesPerRow + srcX;
final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
(srcX ~/ 2) * uPlane.bytesPerPixel!;
final int yVal = yPlane.bytes[yIndex];
final int uVal = uPlane.bytes[uvIndex] - 128;
final int vVal = vPlane.bytes[uvIndex] - 128;
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
.clamp(0, 255)
.toInt();
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
image.setPixelRgb(x, y, r, g, b);
}
}
return image;
} catch (e) {
debugPrint('Erreur conversion frame: $e');
return null;
if (mounted) setState(() => _isLoading = false);
}
}
@@ -385,420 +172,45 @@ class _CaptureScreenState extends State<CaptureScreen>
);
}
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
if (_showLiveCamera &&
_cameraController != null &&
_cameraController!.value.isInitialized) {
return _buildLiveCameraView();
}
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Scanner',
onPressed: _isLoading ? null : _initLiveCamera,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
// ─────────────────────────────────────────────────────────────────────────
Widget _buildLiveCameraView() {
// Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage)
// Si l'IMU dit "mal aligné" → orange, sinon → vert
final Color frameColor = _frameColor;
final String alignmentMessage = _alignmentMessage;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Flux vidéo caméra
Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: CameraPreview(_cameraController!),
),
),
// 1.bis Cercle dessiné autour de la cible circulaire détectée
if (_targetResult != null && _targetResult!.success)
Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: CustomPaint(
painter: _TargetCirclePainter(
target: _targetResult!,
color: _targetReady ? frameColor : Colors.white,
highlighted: _targetReady,
),
),
),
),
// 2. Cadre de visée avec coins et mire centrale
Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration(
border: Border.all(color: frameColor, width: 2.0),
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
_buildCameraCorner(TopLeft: true, color: frameColor),
_buildCameraCorner(TopRight: true, color: frameColor),
_buildCameraCorner(BottomLeft: true, color: frameColor),
_buildCameraCorner(BottomRight: true, color: frameColor),
Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: frameColor, width: 1.2),
),
child: Stack(
children: [
Center(
child: Container(
width: 20,
height: 1.2,
color: frameColor,
),
),
Center(
child: Container(
width: 1.2,
height: 20,
color: frameColor,
),
),
],
),
),
),
],
),
),
),
// 3. Bouton retour
Positioned(
top: 40,
left: 20,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
setState(() {
_showLiveCamera = false;
});
},
),
),
),
// 4. Message d'alignement principal (texte dynamique IMU)
Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
alignmentMessage,
style: TextStyle(
color: frameColor,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
),
),
),
),
),
// NOUVEAU : Indicateur visuel des angles en bas à gauche
if (_parallelismData != null &&
_parallelismData!.status != ParallelismStatus.unknown)
Positioned(
bottom: 140,
left: 16,
child: _buildAngleIndicator(_parallelismData!),
),
// Indicateur d'analyse en cours
if (_isAnalyzingFrame)
Positioned(
top: 45,
right: 20,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white54,
shape: BoxShape.circle,
),
),
),
// 5. Bouton déclencheur
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: _takePictureManually,
child: Container(
height: 80,
width: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: Center(
child: Container(
height: 64,
width: 64,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
),
),
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Widget affichant les angles pitch/roll en temps réel
// ─────────────────────────────────────────────────────────────────────────
Widget _buildAngleIndicator(ParallelismData data) {
final Color color = data.isAligned ? const Color(0xFF00FF00) : Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildAngleRow(
label: 'Pitch',
value: data.pitchDegrees,
color: color,
),
const SizedBox(height: 2),
_buildAngleRow(
label: 'Roll ',
value: data.rollDegrees,
color: color,
),
],
),
);
}
Widget _buildAngleRow({
required String label,
required double value,
required Color color,
}) {
final String sign = value >= 0 ? '+' : '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
fontFamily: 'monospace',
),
),
Text(
'$sign${value.toStringAsFixed(1)}°',
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────
// Coins du cadre (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildCameraCorner({
bool TopLeft = false,
bool TopRight = false,
bool BottomLeft = false,
bool BottomRight = false,
Color color = const Color(0xFF00FF00),
}) {
return Positioned(
top: (TopLeft || TopRight) ? 10 : null,
bottom: (BottomLeft || BottomRight) ? 10 : null,
left: (TopLeft || BottomLeft) ? 10 : null,
right: (TopRight || BottomRight) ? 10 : null,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
border: Border(
top: (TopLeft || TopRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
bottom: (BottomLeft || BottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
left: (TopLeft || BottomLeft)
? BorderSide(color: color, width: 4)
: BorderSide.none,
right: (TopRight || BottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
),
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Capture manuelle (inchangée, sauf ajout stop IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
try {
await _cameraController!.setZoomLevel(1.0);
} catch (_) {}
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
final XFile photo = await _cameraController!.takePicture();
// NOUVEAU : redressement automatique de la cible (warp perspective).
// On écrit le résultat dans un fichier dédié, à côté de l'original.
String finalPath = photo.path;
try {
final tempDir = await getTemporaryDirectory();
final rectifiedPath =
'${tempDir.path}/rectified_${DateTime.now().millisecondsSinceEpoch}.jpg';
final result = await _rectifyService.rectify(
inputPath: photo.path,
outputPath: rectifiedPath,
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
// PERF : limite d'import plus agressive pour accélérer le pipeline
maxWidth: 1280,
maxHeight: 1280,
imageQuality: 75,
);
finalPath = result.outputPath;
if (mounted && result.rectified) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
'Cible redressée automatiquement '
'(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)',
),
),
);
}
} catch (e) {
debugPrint('Redressement ignoré: $e');
finalPath = photo.path; // on garde la photo originale en secours
}
if (image != null) {
setState(() {
_selectedImagePath = finalPath;
_showLiveCamera = false;
_selectedImagePath = image.path;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
} finally {
setState(() => _isLoading = false);
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI (inchangés)
// Helpers UI
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
@@ -855,105 +267,4 @@ class _CaptureScreenState extends State<CaptureScreen>
),
);
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Painter qui dessine un cercle autour de la cible circulaire détectée.
// Le centre et le rayon viennent d'OpenCV (coordonnées normalisées 0..1).
// Blanc tant que la cible n'est pas bien cadrée, couleur d'état sinon.
// ─────────────────────────────────────────────────────────────────────────
class _TargetCirclePainter extends CustomPainter {
final TargetDetectionResult target;
final Color color;
final bool highlighted;
_TargetCirclePainter({
required this.target,
required this.color,
required this.highlighted,
});
@override
void paint(Canvas canvas, Size size) {
// Centre en pixels dans la zone de dessin
final double cx = target.centerX * size.width;
final double cy = target.centerY * size.height;
// radius est normalisé par min(largeur, hauteur) côté OpenCV
final double r = target.radius * math.min(size.width, size.height);
final Paint stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = highlighted ? 3.5 : 2.0
..color = color;
// Cercle principal autour de la cible
canvas.drawCircle(Offset(cx, cy), r, stroke);
// Petite croix au centre détecté
final Paint cross = Paint()
..strokeWidth = 2.0
..color = color;
const double k = 10;
canvas.drawLine(Offset(cx - k, cy), Offset(cx + k, cy), cross);
canvas.drawLine(Offset(cx, cy - k), Offset(cx, cy + k), cross);
// Étiquette "CIBLE" quand elle est bien cadrée
if (highlighted) {
final tp = TextPainter(
text: TextSpan(
text: ' CIBLE ',
style: TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold,
backgroundColor: color,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(cx - tp.width / 2, (cy - r - 20).clamp(0.0, size.height)));
}
}
@override
bool shouldRepaint(_TargetCirclePainter old) =>
old.target != target ||
old.color != color ||
old.highlighted != highlighted;
}

View File

@@ -389,23 +389,27 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
final savedScale = _scale;
final savedOffset = _offset;
_scale = 1.0;
_offset = Offset.zero;
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à
// celui utilisé pour afficher l'image dans l'aperçu.
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
final double displayWidth = imageAspect > viewportAspect
? _viewportSize.width
: _viewportSize.height * imageAspect;
final double displayPerSourcePx = displayWidth / _imageSize!.width;
final cropRect = _calculateCropRect();
// On restaure pour l'affichage (au cas où on revient en arrière)
_scale = savedScale;
_offset = savedOffset;
// AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE
final croppedImagePath = await _cropService.cropToSquare(
widget.imagePath,
cropRect,
// 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
// sont remplis en noir → la position choisie est respectée à l'identique.
final croppedImagePath = await _cropService.cropViewport(
sourcePath: widget.imagePath,
offsetDx: _offset.dx,
offsetDy: _offset.dy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: _cropSize,
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
// il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation,
);
@@ -419,6 +423,10 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedImagePath,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
originalImagePath: widget.imagePath,
targetType: widget.targetType,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
@@ -438,43 +446,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

@@ -0,0 +1,54 @@
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

@@ -98,11 +98,152 @@ void _cropIsolateEntry(_CropParams params) {
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 {
final Uuid _uuid = const Uuid();
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(
String sourcePath,
CropRect cropRect, {