Compare commits
5 Commits
avec-mlkit
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e27d01f17 | ||
|
|
8dd78f6b80 | ||
|
|
23c3bb178f | ||
|
|
18e591f3fc | ||
|
|
13cf5b70e0 |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter analyze:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal 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
1
.gitignore
vendored
@@ -11,7 +11,6 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
.claude/
|
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(() {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user