Compare commits
16 Commits
main
...
avec-mlkit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2172e3ec | ||
|
|
600a6bef30 | ||
|
|
5d2e612b9c | ||
|
|
4e26431be5 | ||
|
|
559369c84d | ||
|
|
653aa5d5b0 | ||
|
|
fec33a327a | ||
|
|
8e64839fc6 | ||
|
|
331fda0ffb | ||
|
|
064f5c1fe3 | ||
|
|
b4226216eb | ||
|
|
2107f187b6 | ||
|
|
0c509bfdc3 | ||
|
|
71ad670ad8 | ||
|
|
625810234e | ||
|
|
9afe3c8508 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(flutter analyze:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
.claude/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
284
lib/features/analysis/impact_editor_screen.dart
Normal file
284
lib/features/analysis/impact_editor_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/services/document_scanner_service.dart
Normal file
54
lib/services/document_scanner_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user