5 Commits

Author SHA1 Message Date
qguillaume
1e27d01f17 fix: on enleve les 85% qui rognent puisqu on utilise mlkit et non le cadre vert 2026-06-07 01:06:11 +02:00
qguillaume
8dd78f6b80 feat: test effacer partie overlay 2026-06-06 11:22:53 +02:00
qguillaume
23c3bb178f feat: reintegration (en cours) de ml kit lecteur de doc 2026-06-06 01:32:16 +02:00
qguillaume
18e591f3fc fix: legere baisse resolution pour augmenter fluidité de l appli 2026-06-06 00:26:38 +02:00
qguillaume
13cf5b70e0 fix: retour arriere leger zoom du au 85% crop 2026-06-05 23:55:43 +02:00
11 changed files with 371 additions and 691 deletions

7
.claude/settings.json Normal file
View File

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

View File

@@ -0,0 +1,16 @@
{
"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,7 +11,6 @@
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
.claude/
# IntelliJ related # IntelliJ related
*.iml *.iml

View File

@@ -37,7 +37,6 @@
<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,7 +19,6 @@ 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';
@@ -29,7 +28,11 @@ 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;
@@ -40,7 +43,7 @@ class AnalysisScreen extends StatelessWidget {
const AnalysisScreen({ const AnalysisScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
this.originalImagePath, // AJOUT this.originalImagePath,
required this.targetType, required this.targetType,
this.initialCenterX, this.initialCenterX,
this.initialCenterY, this.initialCenterY,
@@ -78,7 +81,7 @@ class AnalysisScreen extends StatelessWidget {
return p; return p;
}, },
child: _AnalysisScreenContent( child: _AnalysisScreenContent(
originalImagePath: originalImagePath, // AJOUT originalImagePath: originalImagePath ?? imagePath,
cropScale: cropScale, cropScale: cropScale,
cropOffset: cropOffset, cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage cropRotation: cropRotation, // Envoyé à la structure d'affichage
@@ -88,13 +91,13 @@ class AnalysisScreen extends StatelessWidget {
} }
class _AnalysisScreenContent extends StatefulWidget { class _AnalysisScreenContent extends StatefulWidget {
final String? originalImagePath; // AJOUT final String originalImagePath;
final double? cropScale; final double? cropScale;
final Offset? cropOffset; final Offset? cropOffset;
final double? cropRotation; final double? cropRotation;
const _AnalysisScreenContent({ const _AnalysisScreenContent({
this.originalImagePath, // AJOUT required this.originalImagePath,
this.cropScale, this.cropScale,
this.cropOffset, this.cropOffset,
this.cropRotation, this.cropRotation,
@@ -157,56 +160,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
} }
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer. /// Revient au Centrage en repartant de l'image ORIGINALE.
/// /// On ne restaure que la rotation (jamais le zoom) → pas de cumul.
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting void _goBackToCrop(AnalysisProvider provider) {
/// persiste dans le TransformationController et se réapplique au retour, Navigator.pushReplacement(
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours context,
/// 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: (_) => ChangeNotifierProvider<AnalysisProvider>.value( builder: (context) => CropScreen(
value: provider, imagePath: widget.originalImagePath,
child: const ImpactEditorScreen(), originalImagePath: widget.originalImagePath,
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
@@ -230,22 +197,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
onPressed: () { onPressed: () {
if (_isCalibrating) { if (_isCalibrating) {
final provider = context.read<AnalysisProvider>(); final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement( _goBackToCrop(provider);
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 {
// Retour Plotting -> Calibration : on réinitialise le zoom. setState(() => _isCalibrating = true);
_enterCalibration();
} }
}, },
), ),
@@ -260,14 +214,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
if (_isCalibrating) if (_isCalibrating)
TextButton( TextButton(
onPressed: () { onPressed: () => setState(() => _isCalibrating = false),
// 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(
@@ -361,7 +308,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
], ],
) )
: _buildReadOnlyPlotImage(context, provider), : _buildZoomableImageWithOverlay(context, provider),
), ),
if (!_isCalibrating) if (!_isCalibrating)
@@ -369,27 +316,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.18),
child: ListTile(
leading: const Icon(
Icons.edit_location_alt,
color: AppTheme.primaryColor,
),
title: const Text('Modifier les impacts'),
subtitle: const Text(
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
),
trailing: const Icon(
Icons.open_in_full,
size: 16,
),
// Rouvre l'éditeur plein écran en partageant le provider.
onTap: () =>
_openImpactEditor(context.read<AnalysisProvider>()),
),
),
const SizedBox(height: 12),
Card( Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1), color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile( child: ListTile(
@@ -405,8 +331,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Icons.arrow_forward_ios, Icons.arrow_forward_ios,
size: 16, size: 16,
), ),
// Retour vers la calibration : on réinitialise le zoom. onTap: () => setState(() => _isCalibrating = true),
onTap: () => _enterCalibration(),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -559,13 +484,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
); );
} }
/// Affichage du plotting en LECTURE SEULE. Widget _buildZoomableImageWithOverlay(
///
/// 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,
) { ) {
@@ -573,30 +492,126 @@ 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(80), boundaryMargin: const EdgeInsets.all(double.infinity),
panEnabled: true, panEnabled: _movingShotId == null,
child: Stack( child: Transform(
children: [ transform: Matrix4.identity()
Image.file( ..setTranslationRaw(
File(provider.imagePath!), widget.cropOffset?.dx ?? 0.0,
key: _imageKey, widget.cropOffset?.dy ?? 0.0,
fit: BoxFit.contain, 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;
});
}
},
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),
),
),
],
), ),
); );
} }
@@ -698,18 +713,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: path, imagePath: widget.originalImagePath,
originalImagePath: widget.originalImagePath,
targetType: type, targetType: type,
initialScale: widget.cropScale, initialRotation: provider.cropRotation,
initialOffset: widget.cropOffset,
), ),
), ),
); );

View File

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

View File

@@ -11,6 +11,7 @@ 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 {
@@ -22,6 +23,7 @@ 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)
@@ -177,10 +179,9 @@ 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,
// PERF : limite d'import plus agressive pour accélérer le pipeline maxWidth: 2048,
maxWidth: 1280, maxHeight: 2048,
maxHeight: 1280, imageQuality: 90,
imageQuality: 75,
); );
if (image != null) { if (image != null) {
setState(() { setState(() {

View File

@@ -9,17 +9,28 @@ 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;
final Offset? initialOffset; /// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
/// 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.initialScale, this.initialRotation,
this.initialOffset,
}); });
@override @override
@@ -45,14 +56,20 @@ 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(widget.imagePath); final file = File(_workingImagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes()); final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -251,10 +268,11 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
// On passe de 0.95 à 0.85 pour matcher parfaitement avec l'appareil photo ! // Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
_cropSize = math.min(displayWidth, displayHeight)* 0.85; // au lieu des 85% précédents qui rognaient inutilement les bords.
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) { if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition(); _initializeImagePosition();
} }
@@ -273,7 +291,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(widget.imagePath), File(_workingImagePath),
fit: BoxFit.contain, fit: BoxFit.contain,
width: _viewportSize.width, width: _viewportSize.width,
height: _viewportSize.height, height: _viewportSize.height,
@@ -358,18 +376,10 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
final minDisplayDim = math.min(displayWidth, displayHeight); // On repart TOUJOURS de l'image entière, centrée, sans zoom :
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport) _scale = 1.0;
_scale = widget.initialScale ?? 1.0; _offset = Offset.zero;
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) {
@@ -389,27 +399,24 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async { Future<void> _onCropConfirm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à // CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
// celui utilisé pour afficher l'image dans l'aperçu. // la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
final imageAspect = _imageSize!.width / _imageSize!.height; final savedScale = _scale;
final viewportAspect = _viewportSize.width / _viewportSize.height; final savedOffset = _offset;
final double displayWidth = imageAspect > viewportAspect _scale = 1.0;
? _viewportSize.width _offset = Offset.zero;
: _viewportSize.height * imageAspect;
final double displayPerSourcePx = displayWidth / _imageSize!.width;
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la final cropRect = _calculateCropRect();
// 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. // On restaure pour l'affichage (au cas où on revient en arrière)
final croppedImagePath = await _cropService.cropViewport( _scale = savedScale;
sourcePath: widget.imagePath, _offset = savedOffset;
offsetDx: _offset.dx,
offsetDy: _offset.dy, // Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
displayPerSourcePx: displayPerSourcePx, // résultat déjà recadré.
cropSizeDisplay: _cropSize, final croppedImagePath = await _cropService.cropToSquare(
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ; _workingImagePath,
// il n'agrandit pas le rendu de sortie. cropRect,
zoomScale: _scale,
rotationDegrees: _rotation, rotationDegrees: _rotation,
); );
@@ -423,16 +430,13 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: croppedImagePath, imagePath: croppedImagePath,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière. // On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%, // revenir au centrage sans jamais re-recadrer le résultat.
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour. originalImagePath: _workingImagePath,
originalImagePath: widget.imagePath, cropRotation: _rotation,
targetType: widget.targetType, targetType: widget.targetType,
initialCenterX: targetCenterX, initialCenterX: targetCenterX,
initialCenterY: targetCenterY, initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
), ),
), ),
); );
@@ -446,4 +450,43 @@ 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,7 +55,37 @@ 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,
@@ -98,152 +128,11 @@ 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, {