Implémentation du zoom et pan pour le mode ajout d'impact

corrigé les impactes en mode zoom
This commit is contained in:
2026-01-18 16:31:45 +01:00
parent d3bbc9c718
commit 6b0cb8f837
7 changed files with 1034 additions and 273 deletions

View File

@@ -0,0 +1,343 @@
/// Écran de recadrage d'image en format carré (1:1).
///
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
library;
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
final String imagePath;
final TargetType targetType;
const CropScreen({
super.key,
required this.imagePath,
required this.targetType,
});
@override
State<CropScreen> createState() => _CropScreenState();
}
class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService();
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
// Position et échelle de l'image
Offset _offset = Offset.zero;
double _scale = 1.0;
double _baseScale = 1.0;
Offset _startFocalPoint = Offset.zero;
Offset _startOffset = Offset.zero;
// Dimensions calculées
double _cropSize = 0;
Size _viewportSize = Size.zero;
@override
void initState() {
super.initState();
_loadImageDimensions();
}
Future<void> _loadImageDimensions() async {
final file = File(widget.imagePath);
final bytes = await file.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
setState(() {
_imageSize = Size(
frame.image.width.toDouble(),
frame.image.height.toDouble(),
);
_imageLoaded = true;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Recadrer'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
actions: [
if (!_isLoading)
IconButton(
icon: const Icon(Icons.check),
onPressed: _onCropConfirm,
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (!_imageLoaded || _imageSize == null) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 16),
Text(
'Recadrage en cours...',
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Taille du carré de crop (90% de la plus petite dimension)
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
// Calculer l'échelle initiale si pas encore fait
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
child: Stack(
children: [
// Image transformée
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale, 1.0),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
// Overlay de recadrage
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(
cropSize: _cropSize,
showGrid: true,
),
),
),
// Instructions en bas
Positioned(
left: 0,
right: 0,
bottom: 24,
child: _buildInstructions(),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
final minDisplayDim = math.min(displayWidth, displayHeight);
_scale = _cropSize / minDisplayDim;
// S'assurer d'un scale minimum
if (_scale < 1.0) _scale = 1.0;
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
// Mise à jour du scale
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
// Mise à jour de la position
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
void _onScaleEnd(ScaleEndDetails details) {
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
}
Widget _buildInstructions() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app,
color: Colors.white.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
Text(
'Déplacez et zoomez pour cadrer la cible',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
),
],
),
);
}
Future<void> _onCropConfirm() async {
if (_imageSize == null) return;
setState(() {
_isLoading = true;
});
try {
// Calculer la zone de crop en coordonnées normalisées de l'image
final cropRect = _calculateCropRect();
// Recadrer l'image
final croppedPath = await _cropService.cropToSquare(
widget.imagePath,
cropRect,
);
if (!mounted) return;
// Naviguer vers l'écran d'analyse avec l'image recadrée
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedPath,
targetType: widget.targetType,
),
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du recadrage: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
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;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Taille de l'image après scale
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
// Position du centre de l'image dans le viewport
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
// Position du coin supérieur gauche de l'image
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
// Position du carré de crop (centré dans le viewport)
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
// Convertir en coordonnées relatives à l'image affichée
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropSize = _cropSize / scaledWidth;
final relCropSizeY = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
);
}
}