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
11 changed files with 691 additions and 371 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -37,6 +37,7 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- ML Kit Document Scanner : télécharge le module de scan de la cible -->
<meta-data <meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES" android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" /> android:value="docscanner" />

View File

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

View File

@@ -0,0 +1,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(); _initRingRadii();
} }
/// Fige et propage la calibration courante vers le provider.
///
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
/// correspond exactement au dernier réglage validé, sans dépendre d'un
/// éventuel rebuild intermédiaire.
void commitCalibration() {
_notifyChange();
}
void _initRingRadii({bool forceRecalculate = false}) { void _initRingRadii({bool forceRecalculate = false}) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine // CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) { if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {

View File

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

View File

@@ -11,7 +11,6 @@ import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
import '../../services/image_crop_service.dart';
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
@@ -23,7 +22,6 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric; final TargetType _selectedType = TargetType.concentric;
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement) // Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
@@ -179,9 +177,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
try { try {
final XFile? image = await _picker.pickImage( final XFile? image = await _picker.pickImage(
source: ImageSource.gallery, source: ImageSource.gallery,
maxWidth: 2048, // PERF : limite d'import plus agressive pour accélérer le pipeline
maxHeight: 2048, maxWidth: 1280,
imageQuality: 90, maxHeight: 1280,
imageQuality: 75,
); );
if (image != null) { if (image != null) {
setState(() { setState(() {

View File

@@ -9,28 +9,17 @@ import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart'; import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget { class CropScreen extends StatefulWidget {
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
/// cumulatif.
final String imagePath; final String imagePath;
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
/// considéré comme l'original (premier passage depuis la caméra/galerie).
final String? originalImagePath;
final TargetType targetType; final TargetType targetType;
final double? initialScale;
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement final Offset? initialOffset;
/// PAS restauré : on repart toujours de l'image entière.
final double? initialRotation;
const CropScreen({ const CropScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
this.originalImagePath,
required this.targetType, required this.targetType,
this.initialRotation, this.initialScale,
this.initialOffset,
}); });
@override @override
@@ -56,20 +45,14 @@ class _CropScreenState extends State<CropScreen> {
late Size _viewportSize; late Size _viewportSize;
late double _cropSize; late double _cropSize;
/// L'image effectivement travaillée par cet écran : toujours l'originale.
String get _workingImagePath =>
widget.originalImagePath ?? widget.imagePath;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// On restaure uniquement la rotation (pas le zoom).
_rotation = widget.initialRotation ?? 0.0;
_loadImageInfo(); _loadImageInfo();
} }
Future<void> _loadImageInfo() async { Future<void> _loadImageInfo() async {
final file = File(_workingImagePath); final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes()); final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -268,11 +251,10 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum), // On passe de 0.95 à 0.85 pour matcher parfaitement avec l'appareil photo !
// au lieu des 85% précédents qui rognaient inutilement les bords. _cropSize = math.min(displayWidth, displayHeight)* 0.85;
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero) { if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition(); _initializeImagePosition();
} }
@@ -291,7 +273,7 @@ class _CropScreenState extends State<CropScreen> {
..rotateZ(_rotation * (math.pi / 180)), ..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
File(_workingImagePath), File(widget.imagePath),
fit: BoxFit.contain, fit: BoxFit.contain,
width: _viewportSize.width, width: _viewportSize.width,
height: _viewportSize.height, height: _viewportSize.height,
@@ -376,10 +358,18 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
// On repart TOUJOURS de l'image entière, centrée, sans zoom : final minDisplayDim = math.min(displayWidth, displayHeight);
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
_scale = 1.0; // 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_offset = Offset.zero; _scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
} }
void _onScaleStart(ScaleStartDetails details) { void _onScaleStart(ScaleStartDetails details) {
@@ -399,24 +389,27 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async { Future<void> _onCropConfirm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer // Facteur d'échelle affichage/source (BoxFit.contain) — identique à
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants // celui utilisé pour afficher l'image dans l'aperçu.
final savedScale = _scale; final imageAspect = _imageSize!.width / _imageSize!.height;
final savedOffset = _offset; final viewportAspect = _viewportSize.width / _viewportSize.height;
_scale = 1.0; final double displayWidth = imageAspect > viewportAspect
_offset = Offset.zero; ? _viewportSize.width
: _viewportSize.height * imageAspect;
final double displayPerSourcePx = displayWidth / _imageSize!.width;
final cropRect = _calculateCropRect(); // Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// On restaure pour l'affichage (au cas où on revient en arrière) // sont remplis en noir → la position choisie est respectée à l'identique.
_scale = savedScale; final croppedImagePath = await _cropService.cropViewport(
_offset = savedOffset; sourcePath: widget.imagePath,
offsetDx: _offset.dx,
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un offsetDy: _offset.dy,
// résultat déjà recadré. displayPerSourcePx: displayPerSourcePx,
final croppedImagePath = await _cropService.cropToSquare( cropSizeDisplay: _cropSize,
_workingImagePath, // Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
cropRect, // il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation, rotationDegrees: _rotation,
); );
@@ -430,13 +423,16 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: croppedImagePath, imagePath: croppedImagePath,
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir // AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// revenir au centrage sans jamais re-recadrer le résultat. // Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
originalImagePath: _workingImagePath, // provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
cropRotation: _rotation, originalImagePath: widget.imagePath,
targetType: widget.targetType, targetType: widget.targetType,
initialCenterX: targetCenterX, initialCenterX: targetCenterX,
initialCenterY: targetCenterY, initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
), ),
), ),
); );
@@ -450,43 +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

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