/// É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 createState() => _CropScreenState(); } class _CropScreenState extends State { 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 _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 _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)), ); } }